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:
gsxdsm
2026-04-06 13:53:14 -07:00
parent dbb310ee67
commit da6b9bbd98
8 changed files with 743 additions and 7 deletions

View File

@@ -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", () => { describe("Git Management API", () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;

View File

@@ -1834,6 +1834,15 @@ export function fetchAgentStats(projectId?: string): Promise<AgentStats> {
return api<AgentStats>(withProjectId("/agents/stats", projectId)); 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 ──────────────────────────────────────────────────── // ── Agent Generation API ────────────────────────────────────────────────────
/** Generated agent specification returned by the AI */ /** Generated agent specification returned by the AI */

View File

@@ -2,10 +2,11 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw, Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy, Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2 ExternalLink, CheckCircle, XCircle, Loader2, GitBranch
} from "lucide-react"; } from "lucide-react";
import type { AgentDetail, AgentState, AgentHeartbeatRun } from "../api"; 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"; import type { AgentLogEntry } from "@fusion/core";
/** /**
@@ -44,14 +45,16 @@ interface AgentDetailViewProps {
projectId?: string; projectId?: string;
onClose: () => void; onClose: () => void;
addToast: (message: string, type?: "success" | "error") => 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 }[] = [ const TABS: { id: TabId; label: string; icon: typeof Activity }[] = [
{ id: "dashboard", label: "Dashboard", icon: ActivitySquare }, { id: "dashboard", label: "Dashboard", icon: ActivitySquare },
{ id: "logs", label: "Logs", icon: FileText }, { id: "logs", label: "Logs", icon: FileText },
{ id: "runs", label: "Runs", icon: Activity }, { id: "runs", label: "Runs", icon: Activity },
{ id: "children", label: "Children", icon: GitBranch },
{ id: "config", label: "Settings", icon: Settings }, { 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)" }, 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 [agent, setAgent] = useState<AgentDetail | null>(null);
const [logs, setLogs] = useState<AgentLogEntry[]>([]); const [logs, setLogs] = useState<AgentLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -385,6 +388,14 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast }: Agent
onSaved={loadAgent} onSaved={loadAgent}
/> />
)} )}
{activeTab === "children" && (
<ChildrenTab
agentId={agent.id}
projectId={projectId}
onChildClick={onChildClick}
/>
)}
</div> </div>
{/* Footer with agent ID */} {/* Footer with agent ID */}
@@ -1038,3 +1049,87 @@ function ConfigTab({
</div> </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>
);
}

View File

@@ -1,12 +1,14 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import type { JSX } 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 type { Agent, AgentCapability, AgentState } from "../api";
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api"; import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun } from "../api";
import { AgentDetailView } from "./AgentDetailView"; import { AgentDetailView } from "./AgentDetailView";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel"; import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { AgentMetricsBar } from "./AgentMetricsBar"; import { AgentMetricsBar } from "./AgentMetricsBar";
import { useAgents } from "../hooks/useAgents"; import { useAgents } from "../hooks/useAgents";
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
import type { AgentNode } from "../hooks/useAgentHierarchy";
import { NewAgentDialog } from "./NewAgentDialog"; import { NewAgentDialog } from "./NewAgentDialog";
export interface AgentsViewProps { 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)" }, 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) { export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const { activeAgents, stats } = useAgents(projectId); const { activeAgents, stats } = useAgents(projectId);
const [agents, setAgents] = useState<Agent[]>([]); const [agents, setAgents] = useState<Agent[]>([]);
@@ -40,10 +130,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [filterState, setFilterState] = useState<AgentState | "all">("all"); const [filterState, setFilterState] = useState<AgentState | "all">("all");
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); 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"; if (typeof window === "undefined") return "list";
const saved = localStorage.getItem("kb-agent-view"); 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 // Persist view preference to localStorage
@@ -54,6 +144,8 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null); const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
const roleSelectRef = useRef<HTMLSelectElement>(null); const roleSelectRef = useRef<HTMLSelectElement>(null);
const hierarchy = useAgentHierarchy(agents);
const loadAgents = useCallback(async () => { const loadAgents = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@@ -183,6 +275,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
> >
<List size={16} /> <List size={16} />
</button> </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> </div>
<button <button
className="btn-icon" className="btn-icon"
@@ -239,6 +340,30 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<ActiveAgentsPanel agents={activeAgents} /> <ActiveAgentsPanel agents={activeAgents} />
{/* Agent List */} {/* 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"}> <div className={agentView === "board" ? "agent-board" : "agent-list"}>
{agents.length === 0 ? ( {agents.length === 0 ? (
<div className="agent-empty"> <div className="agent-empty">
@@ -583,6 +708,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
}) })
)} )}
</div> </div>
)}
</div> </div>
{/* Agent Detail Modal */} {/* Agent Detail Modal */}
@@ -592,6 +718,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
projectId={projectId} projectId={projectId}
onClose={() => setSelectedAgentId(null)} onClose={() => setSelectedAgentId(null)}
addToast={addToast} addToast={addToast}
onChildClick={(childId) => setSelectedAgentId(childId)}
/> />
)} )}

View 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");
});
});

View 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
};
}

View File

@@ -19701,6 +19701,120 @@ html .column.drag-over * {
border-radius: var(--radius-sm); 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 { .agent-board {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));

View File

@@ -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 ────────────────────────────────────────────── // ── Agent Generation Routes ──────────────────────────────────────────────
/** /**