feat(FN-984): add agent hierarchy tree view with parent-child relationships
- Add GET /agents/:id/children API endpoint to fetch child agents of a parent - Create fetchAgentChildren API function with tests in dashboard client - Implement useAgentHierarchy hook with recursive tree loading and expand/collapse - Add tree view mode to AgentsView with hierarchical agent display - Enhance AgentDetailView with parent info and expandable children section - Add tree view CSS styles for connection lines and expand/collapse indicators
This commit is contained in:
@@ -970,6 +970,62 @@ describe("startAgentRun", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAgentChildren", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("fetches children for an agent", async () => {
|
||||
const mockChildren = [
|
||||
{ id: "child-1", name: "Child Agent 1", state: "active", reportsTo: "agent-001" },
|
||||
{ id: "child-2", name: "Child Agent 2", state: "idle", reportsTo: "agent-001" },
|
||||
];
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const result = await fetchAgentChildren("agent-001");
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe("child-1");
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes projectId as query param", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
await fetchAgentChildren("agent-001", "proj_123");
|
||||
|
||||
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns empty array for 404 (agent not found)", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Agent not found" }, 404),
|
||||
);
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
const result = await fetchAgentChildren("agent-999");
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("throws on non-404 errors", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(
|
||||
mockFetchResponse(false, { error: "Internal server error" }, 500),
|
||||
);
|
||||
|
||||
const { fetchAgentChildren } = await import("./api");
|
||||
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Git Management API", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
|
||||
@@ -1834,6 +1834,15 @@ export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
|
||||
return api<AgentStats>(withProjectId("/agents/stats", projectId));
|
||||
}
|
||||
|
||||
/** Fetch child agents that report to a given parent agent */
|
||||
export function fetchAgentChildren(agentId: string, projectId?: string): Promise<Agent[]> {
|
||||
return api<Agent[]>(withProjectId(`/agents/${encodeURIComponent(agentId)}/children`, projectId)).catch((err: Error) => {
|
||||
// Return empty array for 404 (agent may have been deleted)
|
||||
if (err.message.includes("not found")) return [];
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
// ── Agent Generation API ────────────────────────────────────────────────────
|
||||
|
||||
/** Generated agent specification returned by the AI */
|
||||
|
||||
@@ -2,10 +2,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import {
|
||||
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
|
||||
Settings, FileText, ActivitySquare, X, Copy,
|
||||
ExternalLink, CheckCircle, XCircle, Loader2
|
||||
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
|
||||
} from "lucide-react";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogs, fetchAgentChildren } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry } from "@fusion/core";
|
||||
|
||||
/**
|
||||
@@ -44,14 +45,16 @@ interface AgentDetailViewProps {
|
||||
projectId?: string;
|
||||
onClose: () => void;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onChildClick?: (childId: string) => void;
|
||||
}
|
||||
|
||||
type TabId = "dashboard" | "logs" | "config" | "runs";
|
||||
type TabId = "dashboard" | "logs" | "config" | "runs" | "children";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
|
||||
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare },
|
||||
{ id: "logs", label: "Logs", icon: FileText },
|
||||
{ id: "runs", label: "Runs", icon: Activity },
|
||||
{ id: "children", label: "Children", icon: GitBranch },
|
||||
{ id: "config", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
@@ -71,7 +74,7 @@ const RUN_STATUS_ICONS: Record<string, { icon: typeof CheckCircle; color: string
|
||||
terminated: { icon: Square, color: "var(--text-muted, #8b949e)" },
|
||||
};
|
||||
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast }: AgentDetailViewProps) {
|
||||
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick }: AgentDetailViewProps) {
|
||||
const [agent, setAgent] = useState<AgentDetail | null>(null);
|
||||
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -385,6 +388,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
|
||||
onSaved={loadAgent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === "children" && (
|
||||
<ChildrenTab
|
||||
agentId={agent.id}
|
||||
projectId={projectId}
|
||||
onChildClick={onChildClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer with agent ID */}
|
||||
@@ -1038,3 +1049,87 @@ function ConfigTab({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Children Tab ────────────────────────────────────────────────────────────
|
||||
|
||||
function ChildrenTab({
|
||||
agentId,
|
||||
projectId,
|
||||
onChildClick,
|
||||
}: {
|
||||
agentId: string;
|
||||
projectId?: string;
|
||||
onChildClick?: (childId: string) => void;
|
||||
}) {
|
||||
const [children, setChildren] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
fetchAgentChildren(agentId, projectId)
|
||||
.then(setChildren)
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [agentId, projectId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-header">
|
||||
<h3>Child Agents</h3>
|
||||
</div>
|
||||
<div className="detail-section-body" style={{ display: "flex", alignItems: "center", gap: 8, padding: 16 }}>
|
||||
<Loader2 size={16} className="spin" />
|
||||
<span className="text-secondary">Loading children...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-section">
|
||||
<div className="detail-section-header">
|
||||
<h3>Child Agents</h3>
|
||||
<span className="text-secondary">({children.length})</span>
|
||||
</div>
|
||||
<div className="detail-section-body">
|
||||
{children.length === 0 ? (
|
||||
<div className="agent-empty" style={{ padding: 24 }}>
|
||||
<GitBranch size={32} opacity={0.3} />
|
||||
<p>No child agents</p>
|
||||
<p className="text-secondary">This agent has no spawned children</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-tree__children">
|
||||
{children.map((child) => {
|
||||
const stateStyle = STATE_COLORS[child.state as AgentState];
|
||||
return (
|
||||
<div
|
||||
key={child.id}
|
||||
className={`agent-tree__node agent-is-child`}
|
||||
onClick={() => onChildClick?.(child.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onChildClick?.(child.id)}
|
||||
style={{ cursor: onChildClick ? "pointer" : "default" }}
|
||||
>
|
||||
<span className="agent-tree__icon">{child.icon ?? "🤖"}</span>
|
||||
<span className="agent-tree__name">{child.name}</span>
|
||||
<span
|
||||
className="agent-tree__badge"
|
||||
style={{
|
||||
background: stateStyle?.bg ?? "var(--state-idle-bg)",
|
||||
color: stateStyle?.text ?? "var(--state-idle-text)",
|
||||
border: `1px solid ${stateStyle?.border ?? "var(--state-idle-border)"}`,
|
||||
}}
|
||||
>
|
||||
{child.state}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
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, Filter } from "lucide-react";
|
||||
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
|
||||
export interface AgentsViewProps {
|
||||
@@ -33,6 +35,94 @@ const STATE_COLORS: Record<AgentState, { bg: string; text: string; border: strin
|
||||
terminated: { bg: "var(--state-error-bg)", text: "var(--state-error-text)", border: "var(--state-error-border)" },
|
||||
};
|
||||
|
||||
/** Recursive tree node component for agent hierarchy */
|
||||
function AgentTreeNode({
|
||||
node,
|
||||
onSelect,
|
||||
onToggle,
|
||||
isExpanded,
|
||||
getChildCount,
|
||||
getHealthStatus,
|
||||
getRoleIcon,
|
||||
}: {
|
||||
node: AgentNode;
|
||||
onSelect: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
isExpanded: (id: string) => boolean;
|
||||
getChildCount: (id: string) => number;
|
||||
getHealthStatus: (agent: Agent) => { label: string; icon: JSX.Element; color: string };
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
}) {
|
||||
const { agent, children, depth } = node;
|
||||
const childCount = getChildCount(agent.id);
|
||||
const expanded = isExpanded(agent.id);
|
||||
const health = getHealthStatus(agent);
|
||||
const stateStyle = STATE_COLORS[agent.state];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`agent-tree__node${agent.reportsTo ? " agent-is-child" : ""} agent-tree__indent--${Math.min(depth, 4)}`}
|
||||
>
|
||||
<button
|
||||
className={`agent-tree__toggle${childCount === 0 ? " agent-tree__toggle--leaf" : ""}`}
|
||||
onClick={() => childCount > 0 && onToggle(agent.id)}
|
||||
title={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No children"}
|
||||
>
|
||||
{childCount > 0 ? (
|
||||
expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />
|
||||
) : (
|
||||
<Bot size={14} />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className="agent-tree__content"
|
||||
onClick={() => onSelect(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && onSelect(agent.id)}
|
||||
>
|
||||
<span className="agent-tree__icon">{getRoleIcon(agent.role)}</span>
|
||||
<span className="agent-tree__name">{agent.name}</span>
|
||||
<span
|
||||
className="agent-tree__badge"
|
||||
style={{
|
||||
background: stateStyle.bg,
|
||||
color: stateStyle.text,
|
||||
border: `1px solid ${stateStyle.border}`,
|
||||
}}
|
||||
>
|
||||
{agent.state}
|
||||
</span>
|
||||
<span className="agent-tree__health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}
|
||||
</span>
|
||||
{childCount > 0 && (
|
||||
<span className="agent-tree__count text-secondary">({childCount})</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{expanded && children.length > 0 && (
|
||||
<div className="agent-tree__children">
|
||||
{children.map((child) => (
|
||||
<AgentTreeNode
|
||||
key={child.agent.id}
|
||||
node={child}
|
||||
onSelect={onSelect}
|
||||
onToggle={onToggle}
|
||||
isExpanded={isExpanded}
|
||||
getChildCount={getChildCount}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const { activeAgents, stats } = useAgents(projectId);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
@@ -40,10 +130,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"board" | "list">(() => {
|
||||
const [agentView, setAgentView] = useState<"board" | "list" | "tree">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = localStorage.getItem("kb-agent-view");
|
||||
return (saved === "board" || saved === "list") ? saved : "list";
|
||||
return (saved === "board" || saved === "list" || saved === "tree") ? saved : "list";
|
||||
});
|
||||
|
||||
// Persist view preference to localStorage
|
||||
@@ -54,6 +144,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||
|
||||
const hierarchy = useAgentHierarchy(agents);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -183,6 +275,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "tree" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("tree")}
|
||||
title="Tree view"
|
||||
aria-label="Tree view"
|
||||
aria-pressed={agentView === "tree"}
|
||||
>
|
||||
<GitBranch size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
@@ -239,6 +340,30 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
<ActiveAgentsPanel agents={activeAgents} />
|
||||
|
||||
{/* Agent List */}
|
||||
{agentView === "tree" ? (
|
||||
<div className="agent-tree__view">
|
||||
{agents.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>
|
||||
) : (
|
||||
hierarchy.rootNodes.map((node) => (
|
||||
<AgentTreeNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={setSelectedAgentId}
|
||||
onToggle={hierarchy.toggleExpand}
|
||||
isExpanded={hierarchy.isExpanded}
|
||||
getChildCount={(id) => hierarchy.getChildren(id).length}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
|
||||
{agents.length === 0 ? (
|
||||
<div className="agent-empty">
|
||||
@@ -583,6 +708,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
@@ -592,6 +718,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
projectId={projectId}
|
||||
onClose={() => setSelectedAgentId(null)}
|
||||
addToast={addToast}
|
||||
onChildClick={(childId) => setSelectedAgentId(childId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
197
packages/dashboard/app/hooks/__tests__/useAgentHierarchy.test.ts
Normal file
197
packages/dashboard/app/hooks/__tests__/useAgentHierarchy.test.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { useAgentHierarchy } from "../useAgentHierarchy";
|
||||
import type { Agent, AgentCapability, AgentState } from "../../api";
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: vi.fn((key: string) => localStorageStore[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string) => {
|
||||
delete localStorageStore[key];
|
||||
}),
|
||||
clear: vi.fn(() => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
}),
|
||||
};
|
||||
vi.stubGlobal("localStorage", localStorageMock);
|
||||
|
||||
function createMockAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "idle" as AgentState,
|
||||
metadata: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => localStorageStore[key] ?? null);
|
||||
localStorageMock.setItem.mockImplementation((key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
});
|
||||
localStorageMock.clear.mockImplementation(() => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorageMock.clear();
|
||||
});
|
||||
|
||||
describe("useAgentHierarchy", () => {
|
||||
it("builds tree from flat agents array", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(1);
|
||||
expect(result.current.rootNodes[0].agent.id).toBe("parent-1");
|
||||
expect(result.current.rootNodes[0].depth).toBe(0);
|
||||
});
|
||||
|
||||
it("handles empty agents array", () => {
|
||||
const { result } = renderHook(() => useAgentHierarchy([]));
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(0);
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it("handles agents with no parent-child relationships (all root nodes)", () => {
|
||||
const agent1 = createMockAgent({ id: "agent-1" });
|
||||
const agent2 = createMockAgent({ id: "agent-2" });
|
||||
const agent3 = createMockAgent({ id: "agent-3" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2, agent3]));
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(3);
|
||||
expect(result.current.rootNodes.map((n) => n.agent.id)).toEqual(["agent-1", "agent-2", "agent-3"]);
|
||||
});
|
||||
|
||||
it("handles deeply nested hierarchies (parent -> child -> grandchild)", () => {
|
||||
const parent = createMockAgent({ id: "parent", name: "Parent" });
|
||||
const child = createMockAgent({ id: "child", name: "Child", reportsTo: "parent" });
|
||||
const grandchild = createMockAgent({ id: "grandchild", name: "Grandchild", reportsTo: "child" });
|
||||
|
||||
// Pre-expand parent and child so grandchild shows up
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent", "child"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child, grandchild]));
|
||||
|
||||
expect(result.current.rootNodes).toHaveLength(1);
|
||||
const rootNode = result.current.rootNodes[0];
|
||||
expect(rootNode.agent.id).toBe("parent");
|
||||
expect(rootNode.depth).toBe(0);
|
||||
expect(rootNode.children).toHaveLength(1);
|
||||
expect(rootNode.children[0].agent.id).toBe("child");
|
||||
expect(rootNode.children[0].depth).toBe(1);
|
||||
expect(rootNode.children[0].children).toHaveLength(1);
|
||||
expect(rootNode.children[0].children[0].agent.id).toBe("grandchild");
|
||||
expect(rootNode.children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
|
||||
it("toggles expand state for a node", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
const child = createMockAgent({ id: "child-1", name: "Child", reportsTo: "parent-1" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child]));
|
||||
|
||||
// Initially not expanded
|
||||
expect(result.current.isExpanded("parent-1")).toBe(false);
|
||||
|
||||
// Expand
|
||||
act(() => {
|
||||
result.current.toggleExpand("parent-1");
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded("parent-1")).toBe(true);
|
||||
|
||||
// Collapse
|
||||
act(() => {
|
||||
result.current.toggleExpand("parent-1");
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded("parent-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("persists expand state to localStorage", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpand("parent-1");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"kb-agent-tree-expanded",
|
||||
JSON.stringify(["parent-1"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores expand state from localStorage on mount", () => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === "kb-agent-tree-expanded") return JSON.stringify(["parent-1"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent]));
|
||||
|
||||
expect(result.current.isExpanded("parent-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("isExpanded returns correct state", () => {
|
||||
const agent1 = createMockAgent({ id: "agent-1" });
|
||||
const agent2 = createMockAgent({ id: "agent-2" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([agent1, agent2]));
|
||||
|
||||
expect(result.current.isExpanded("agent-1")).toBe(false);
|
||||
expect(result.current.isExpanded("agent-2")).toBe(false);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpand("agent-1");
|
||||
});
|
||||
|
||||
expect(result.current.isExpanded("agent-1")).toBe(true);
|
||||
expect(result.current.isExpanded("agent-2")).toBe(false);
|
||||
});
|
||||
|
||||
it("getChildren returns direct children for an agent", () => {
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
const child1 = createMockAgent({ id: "child-1", name: "Child 1", reportsTo: "parent-1" });
|
||||
const child2 = createMockAgent({ id: "child-2", name: "Child 2", reportsTo: "parent-1" });
|
||||
const unrelated = createMockAgent({ id: "unrelated", name: "Unrelated" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([parent, child1, child2, unrelated]));
|
||||
|
||||
const children = result.current.getChildren("parent-1");
|
||||
expect(children).toHaveLength(2);
|
||||
expect(children.map((c) => c.id)).toEqual(["child-1", "child-2"]);
|
||||
});
|
||||
|
||||
it("handles agents with reportsTo pointing to non-existent parent", () => {
|
||||
const orphan = createMockAgent({ id: "orphan-1", name: "Orphan", reportsTo: "missing-parent" });
|
||||
const normal = createMockAgent({ id: "normal-1", name: "Normal" });
|
||||
|
||||
const { result } = renderHook(() => useAgentHierarchy([orphan, normal]));
|
||||
|
||||
// Orphan should be treated as a root node since parent doesn't exist
|
||||
expect(result.current.rootNodes).toHaveLength(2);
|
||||
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("orphan-1");
|
||||
expect(result.current.rootNodes.map((n) => n.agent.id)).toContain("normal-1");
|
||||
});
|
||||
});
|
||||
111
packages/dashboard/app/hooks/useAgentHierarchy.ts
Normal file
111
packages/dashboard/app/hooks/useAgentHierarchy.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import type { Agent } from "../api";
|
||||
|
||||
const EXPANDED_KEY = "kb-agent-tree-expanded";
|
||||
|
||||
export interface AgentNode {
|
||||
agent: Agent;
|
||||
children: AgentNode[];
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface UseAgentHierarchyReturn {
|
||||
rootNodes: AgentNode[];
|
||||
toggleExpand: (agentId: string) => void;
|
||||
isExpanded: (agentId: string) => boolean;
|
||||
getChildren: (agentId: string) => Agent[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function readExpandedFromStorage(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(EXPANDED_KEY);
|
||||
if (stored) {
|
||||
const parsed: string[] = JSON.parse(stored);
|
||||
return new Set(Array.isArray(parsed) ? parsed : []);
|
||||
}
|
||||
} catch {
|
||||
// Gracefully degrade if localStorage is unavailable
|
||||
}
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function writeExpandedToStorage(expanded: Set<string>): void {
|
||||
try {
|
||||
localStorage.setItem(EXPANDED_KEY, JSON.stringify([...expanded]));
|
||||
} catch {
|
||||
// Gracefully degrade if localStorage is unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function buildTree(agents: Agent[], expanded: Set<string>): AgentNode[] {
|
||||
const agentMap = new Map<string, Agent>();
|
||||
const childrenMap = new Map<string, Agent[]>();
|
||||
|
||||
for (const agent of agents) {
|
||||
agentMap.set(agent.id, agent);
|
||||
if (agent.reportsTo) {
|
||||
const siblings = childrenMap.get(agent.reportsTo) ?? [];
|
||||
siblings.push(agent);
|
||||
childrenMap.set(agent.reportsTo, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
function buildNode(agent: Agent, depth: number): AgentNode {
|
||||
const childAgents = childrenMap.get(agent.id) ?? [];
|
||||
const children: AgentNode[] = expanded.has(agent.id)
|
||||
? childAgents.map((child) => buildNode(child, depth + 1))
|
||||
: [];
|
||||
|
||||
return { agent, children, depth };
|
||||
}
|
||||
|
||||
// Root nodes are agents with no reportsTo or whose reportsTo points to non-existent agent
|
||||
return agents
|
||||
.filter((agent) => !agent.reportsTo || !agentMap.has(agent.reportsTo))
|
||||
.map((agent) => buildNode(agent, 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing agent hierarchy (parent-child relationships).
|
||||
* Derives the tree structure from the `reportsTo` field on agents.
|
||||
* Expand/collapse state is persisted to localStorage.
|
||||
*/
|
||||
export function useAgentHierarchy(agents: Agent[]): UseAgentHierarchyReturn {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpandedFromStorage());
|
||||
|
||||
const rootNodes = useMemo(() => buildTree(agents, expanded), [agents, expanded]);
|
||||
|
||||
const toggleExpand = useCallback((agentId: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) {
|
||||
next.delete(agentId);
|
||||
} else {
|
||||
next.add(agentId);
|
||||
}
|
||||
writeExpandedToStorage(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isExpanded = useCallback(
|
||||
(agentId: string) => expanded.has(agentId),
|
||||
[expanded],
|
||||
);
|
||||
|
||||
const getChildren = useCallback(
|
||||
(agentId: string): Agent[] => {
|
||||
return agents.filter((a) => a.reportsTo === agentId);
|
||||
},
|
||||
[agents],
|
||||
);
|
||||
|
||||
return {
|
||||
rootNodes,
|
||||
toggleExpand,
|
||||
isExpanded,
|
||||
getChildren,
|
||||
isLoading: false, // tree is derived from pre-fetched agents
|
||||
};
|
||||
}
|
||||
@@ -19701,6 +19701,120 @@ html .column.drag-over * {
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Agent Tree View ────────────────────────────────────────────────────── */
|
||||
|
||||
.agent-tree__view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.agent-tree__node {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.agent-tree__node:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.agent-tree__indent--0 { padding-left: 0; }
|
||||
.agent-tree__indent--1 { padding-left: 24px; }
|
||||
.agent-tree__indent--2 { padding-left: 48px; }
|
||||
.agent-tree__indent--3 { padding-left: 72px; }
|
||||
.agent-tree__indent--4 { padding-left: 96px; }
|
||||
|
||||
.agent-tree__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
color: var(--text-secondary);
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.agent-tree__toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agent-tree__toggle--leaf {
|
||||
color: var(--text-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.agent-tree__toggle--leaf:hover {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-tree__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-tree__icon {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-tree__name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.agent-tree__badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-tree__health {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-tree__count {
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-is-child {
|
||||
background: rgba(124, 92, 191, 0.05);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.agent-tree__children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.agent-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
|
||||
@@ -7069,6 +7069,33 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/agents/:id/children
|
||||
* Fetch agents that report to a given agent (parent-child hierarchy).
|
||||
* Response 200: Agent[] — Array of agents where reportsTo equals :id
|
||||
* Response 404: { error: "Agent not found" } — When parent agent doesn't exist
|
||||
*/
|
||||
router.get("/agents/:id/children", async (req, res) => {
|
||||
try {
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
// Validate the parent agent exists
|
||||
const parent = await agentStore.getAgent(req.params.id);
|
||||
if (!parent) {
|
||||
res.status(404).json({ error: "Agent not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
const children = await agentStore.getAgentsByReportsTo(req.params.id);
|
||||
res.json(children);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── Agent Generation Routes ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user