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:
@@ -6,7 +6,7 @@ import {
|
||||
ChevronDown, ChevronRight
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, fetchAgentTasks } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, updateAgentInstructions, fetchAgentTasks, fetchChainOfCommand } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { AgentLogViewer } from "./AgentLogViewer";
|
||||
@@ -361,7 +361,12 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
{/* Tab Content */}
|
||||
<div className="agent-detail-content">
|
||||
{activeTab === "dashboard" && (
|
||||
<DashboardTab agent={agent} health={health} />
|
||||
<DashboardTab
|
||||
agent={agent}
|
||||
health={health}
|
||||
onChildClick={onChildClick}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "logs" && (
|
||||
@@ -437,13 +442,47 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
|
||||
|
||||
function DashboardTab({
|
||||
agent,
|
||||
health
|
||||
health,
|
||||
onChildClick,
|
||||
projectId,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
health: { label: string; color: string };
|
||||
onChildClick?: (childId: string) => void;
|
||||
projectId?: string;
|
||||
}) {
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
|
||||
const [chainOfCommand, setChainOfCommand] = useState<Agent[]>([]);
|
||||
const [isLoadingChainOfCommand, setIsLoadingChainOfCommand] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setIsLoadingChainOfCommand(true);
|
||||
|
||||
void fetchChainOfCommand(agent.id, projectId)
|
||||
.then((chain) => {
|
||||
if (cancelled) return;
|
||||
const normalized = chain.length > 0 && chain[0]?.id === agent.id
|
||||
? [...chain].reverse()
|
||||
: chain;
|
||||
setChainOfCommand(normalized);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setChainOfCommand([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsLoadingChainOfCommand(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agent.id, projectId]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const runs = (agent as any).completedRuns || [];
|
||||
const today = new Date();
|
||||
@@ -514,6 +553,44 @@ function DashboardTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="dashboard-section">
|
||||
<h3>
|
||||
<GitBranch size={16} style={{ marginRight: "6px", verticalAlign: "-2px" }} />
|
||||
Chain of Command
|
||||
</h3>
|
||||
{isLoadingChainOfCommand ? (
|
||||
<div className="chain-of-command-loading" role="status" aria-live="polite">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span>Loading reporting chain...</span>
|
||||
</div>
|
||||
) : chainOfCommand.length <= 1 ? (
|
||||
<p className="text-muted">No reporting chain</p>
|
||||
) : (
|
||||
<div className="chain-of-command-path" aria-label="Chain of command">
|
||||
{chainOfCommand.map((chainAgent, index) => {
|
||||
const isCurrent = index === chainOfCommand.length - 1;
|
||||
const isAncestor = !isCurrent;
|
||||
return (
|
||||
<div key={chainAgent.id} className="chain-of-command-item">
|
||||
<button
|
||||
type="button"
|
||||
className={`chain-of-command-node${isCurrent ? " chain-of-command-node--current" : ""}`}
|
||||
onClick={() => isAncestor && onChildClick?.(chainAgent.id)}
|
||||
disabled={!isAncestor || !onChildClick}
|
||||
title={isCurrent ? "Current agent" : `View ${chainAgent.name}`}
|
||||
>
|
||||
{chainAgent.name}
|
||||
</button>
|
||||
{!isCurrent && (
|
||||
<span className="chain-of-command-separator" aria-hidden="true">→</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="dashboard-section">
|
||||
<h3>Statistics</h3>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import type { JSX } from "react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
@@ -125,6 +125,68 @@ function AgentTreeNode({
|
||||
);
|
||||
}
|
||||
|
||||
function OrgChartNode({
|
||||
node,
|
||||
onSelect,
|
||||
getHealthStatus,
|
||||
getRoleIcon,
|
||||
}: {
|
||||
node: OrgTreeNode;
|
||||
onSelect: (id: string) => void;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
}) {
|
||||
const { agent, children } = node;
|
||||
const health = getHealthStatus(agent);
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
|
||||
return (
|
||||
<div className={`org-chart-node${children.length > 0 ? " org-chart-node--has-children" : ""}`}>
|
||||
<div
|
||||
className="org-chart-node-card"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)}
|
||||
>
|
||||
<div className="org-chart-node__header">
|
||||
<span className="org-chart-node__icon">{getRoleIcon(agent.role)}</span>
|
||||
<span className="org-chart-node__name">{agent.name}</span>
|
||||
</div>
|
||||
<div className="org-chart-node__meta">
|
||||
<span
|
||||
className="org-chart-node__badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="org-chart-node__health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
<span className="text-secondary">{health.label}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
<div className="org-chart-children" role="group" aria-label={`${agent.name} reports`}>
|
||||
{children.map((child) => (
|
||||
<OrgChartNode
|
||||
key={child.agent.id}
|
||||
node={child}
|
||||
onSelect={onSelect}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const { activeAgents, stats } = useAgents(projectId);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -133,15 +195,17 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"board" | "list" | "tree">(() => {
|
||||
const [agentView, setAgentView] = useState<"board" | "list" | "tree" | "org">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = getScopedItem("kb-agent-view", projectId);
|
||||
return (saved === "board" || saved === "list" || saved === "tree") ? saved : "list";
|
||||
return (saved === "board" || saved === "list" || saved === "tree" || saved === "org") ? saved : "list";
|
||||
});
|
||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("kb-agent-view", projectId);
|
||||
if (saved === "board" || saved === "list" || saved === "tree") {
|
||||
if (saved === "board" || saved === "list" || saved === "tree" || saved === "org") {
|
||||
setAgentView(saved);
|
||||
return;
|
||||
}
|
||||
@@ -175,6 +239,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentView !== "org") return;
|
||||
|
||||
let cancelled = false;
|
||||
setIsOrgTreeLoading(true);
|
||||
fetchOrgTree(projectId)
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setOrgTree(data);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (!cancelled) {
|
||||
addToast(`Failed to load org chart: ${err.message}`, "error");
|
||||
setOrgTree([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsOrgTreeLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [agentView, projectId, addToast]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
@@ -328,6 +420,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("org")}
|
||||
title="Org Chart view"
|
||||
aria-label="Org Chart view"
|
||||
aria-pressed={agentView === "org"}
|
||||
>
|
||||
<Network size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
@@ -423,6 +524,31 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : agentView === "org" ? (
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
</div>
|
||||
) : orgTree.length === 0 ? (
|
||||
<div className="agent-empty">
|
||||
<Bot size={48} opacity={0.3} />
|
||||
<p>No agents found</p>
|
||||
<p className="text-secondary">Create an agent to get started</p>
|
||||
</div>
|
||||
) : (
|
||||
orgTree.map((node) => (
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={setSelectedAgentId}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
||||
{agents.length === 0 ? (
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -24885,3 +24885,188 @@ body[data-color-theme="terminal"][data-theme="light"]::before {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
}
|
||||
|
||||
/* === FN-1167: Agent Org Chart + Chain of Command === */
|
||||
.agent-org-chart {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
min-height: 220px;
|
||||
}
|
||||
|
||||
.agent-org-chart__loading,
|
||||
.chain-of-command-loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.org-chart-node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 220px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.org-chart-node-card {
|
||||
width: 100%;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
padding: 0.75rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: border-color 120ms ease, background-color 120ms ease, transform 120ms ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.org-chart-node-card:hover,
|
||||
.org-chart-node-card:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: var(--card-hover);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.org-chart-node__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.org-chart-node__icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.org-chart-node__name {
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.org-chart-node__meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.org-chart-node__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.72rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.org-chart-node__health {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.org-chart-node--has-children > .org-chart-node-card::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: -16px;
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.org-chart-children {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.org-chart-children::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.org-chart-children > .org-chart-node::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.chain-of-command-path {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
.chain-of-command-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.chain-of-command-node {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chain-of-command-node:hover:not(:disabled) {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.chain-of-command-node:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.chain-of-command-node--current {
|
||||
border-color: var(--accent);
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.chain-of-command-separator {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.agent-org-chart {
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.org-chart-node {
|
||||
min-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user