feat(FN-3121): merge fusion/fn-3121
This merge lands v0.15.0, bringing a major plugin system overhaul including a new dependency graph plugin with SQLite-backed storage, Quick Chat FAB with slash-triggered skill menu, and expanded plugin dashboard views. The AgentsView tree mode was fully removed with its hook, styles, and expansion s Fusion-Task-Id: FN-3121
This commit is contained in:
5
.changeset/remove-agent-tree-view.md
Normal file
5
.changeset/remove-agent-tree-view.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Remove the Agents page tree view mode and its associated state, hierarchy hook, and tree-specific styling. The view switcher now supports list, board, and org chart only, reducing maintenance overhead for an unused mode.
|
||||
@@ -150,24 +150,6 @@ describe("Agent CSS classes", () => {
|
||||
expect(filtersBlock).toContain("border-radius: var(--radius-md)");
|
||||
});
|
||||
|
||||
it("should use design tokens for the updated tree view spacing and transitions", () => {
|
||||
const treeViewBlock = extractRuleBlock(".agent-tree__view");
|
||||
expect(treeViewBlock).toContain("gap: var(--space-xs)");
|
||||
expect(treeViewBlock).toContain("padding: var(--space-sm) 0");
|
||||
|
||||
const treeNodeBlock = extractRuleBlock(".agent-tree__node");
|
||||
expect(treeNodeBlock).toContain("gap: var(--space-sm)");
|
||||
expect(treeNodeBlock).toContain("padding: var(--space-sm) var(--space-md)");
|
||||
expect(treeNodeBlock).toContain("border-radius: var(--radius-sm)");
|
||||
expect(treeNodeBlock).toContain("transition: background-color var(--transition-fast)");
|
||||
expect(treeNodeBlock).not.toMatch(/gap:\s*4px|padding:\s*8px 12px|border-radius:\s*4px|0\.15s\s+ease/);
|
||||
|
||||
expect(extractRuleBlock(".agent-tree__indent--1")).toContain("padding-left: var(--space-xl)");
|
||||
expect(extractRuleBlock(".agent-tree__indent--2")).toContain("padding-left: calc(var(--space-xl) * 2)");
|
||||
expect(extractRuleBlock(".agent-tree__indent--3")).toContain("padding-left: calc(var(--space-xl) * 3)");
|
||||
expect(extractRuleBlock(".agent-tree__indent--4")).toContain("padding-left: calc(var(--space-xl) * 4)");
|
||||
});
|
||||
|
||||
it("should use dashboard tokens in the updated org chart styles", () => {
|
||||
const agentsViewCss = fs.readFileSync(path.join(__dirname, "../components/AgentsView.css"), "utf-8");
|
||||
const orgChartStart = agentsViewCss.indexOf("/* === FN-1167: Agent Org Chart + Chain of Command === */");
|
||||
@@ -384,7 +366,7 @@ describe("Agent CSS classes", () => {
|
||||
|
||||
it("should only keep runtime health color inline styles in AgentsView", () => {
|
||||
const inlineStyleCount = (agentsViewContent.match(/style=\{\{/g) || []).length;
|
||||
expect(inlineStyleCount).toBe(4);
|
||||
expect(inlineStyleCount).toBe(3);
|
||||
});
|
||||
|
||||
it("should not have inline <style> blocks in AgentDetailView", () => {
|
||||
|
||||
@@ -235,14 +235,7 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Agent Tree View ────────────────────────────────────────────────────── */
|
||||
|
||||
.agent-tree__view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
/* ── Agent Tree Node Styles (shared with AgentDetailView) ──────────────── */
|
||||
|
||||
.agent-tree__node {
|
||||
position: relative;
|
||||
@@ -251,62 +244,6 @@
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-sm);
|
||||
transition: background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-tree__node:hover {
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.agent-tree__node--active,
|
||||
.agent-tree__node--running {
|
||||
background: var(--state-active-bg);
|
||||
}
|
||||
|
||||
.agent-tree__indent--0 { padding-left: 0; }
|
||||
.agent-tree__indent--1 { padding-left: var(--space-xl); }
|
||||
.agent-tree__indent--2 { padding-left: calc(var(--space-xl) * 2); }
|
||||
.agent-tree__indent--3 { padding-left: calc(var(--space-xl) * 3); }
|
||||
.agent-tree__indent--4 { padding-left: calc(var(--space-xl) * 4); }
|
||||
|
||||
.agent-tree__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: calc(var(--space-md) * 2);
|
||||
height: calc(var(--space-md) * 2);
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
transition: background-color var(--transition-fast), color var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.agent-tree__toggle:hover {
|
||||
background: var(--card-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.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: var(--space-sm);
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.agent-tree__icon {
|
||||
@@ -332,17 +269,6 @@
|
||||
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: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||
border-left: 2px solid var(--accent);
|
||||
@@ -1025,9 +951,4 @@
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Touch target for agent tree toggle */
|
||||
.agent-tree__toggle {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "./AgentsView.css";
|
||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network, SlidersHorizontal } from "lucide-react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, Filter, Upload, Network, SlidersHorizontal } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
|
||||
@@ -11,8 +11,6 @@ import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
|
||||
import { AgentEmptyState } from "./AgentEmptyState";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { useConfirm } from "../hooks/useConfirm";
|
||||
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||
import { AgentImportModal } from "./AgentImportModal";
|
||||
@@ -67,7 +65,7 @@ function getStateBadgeClass(state: AgentState): string {
|
||||
}
|
||||
|
||||
function getStateCardClass(
|
||||
prefix: "agent-card" | "agent-board-card" | "agent-tree__node" | "org-chart-node-card",
|
||||
prefix: "agent-card" | "agent-board-card" | "org-chart-node-card",
|
||||
state: AgentState,
|
||||
): string {
|
||||
switch (state) {
|
||||
@@ -87,103 +85,6 @@ function getStateCardClass(
|
||||
}
|
||||
}
|
||||
|
||||
/** Recursive tree node component for agent hierarchy */
|
||||
function AgentTreeNode({
|
||||
node,
|
||||
onSelect,
|
||||
onToggle,
|
||||
isExpanded,
|
||||
getChildCount,
|
||||
getHealthStatus,
|
||||
getRoleIcon,
|
||||
getSkillBadges,
|
||||
}: {
|
||||
node: AgentNode;
|
||||
onSelect: (id: string) => void;
|
||||
onToggle: (id: string) => void;
|
||||
isExpanded: (id: string) => boolean;
|
||||
getChildCount: (id: string) => number;
|
||||
getHealthStatus: (agent: Agent) => AgentHealthStatus;
|
||||
getRoleIcon: (role: AgentCapability) => string;
|
||||
getSkillBadges: (agent: Agent) => string[];
|
||||
}) {
|
||||
const { agent, children, depth } = node;
|
||||
const childCount = getChildCount(agent.id);
|
||||
const expanded = isExpanded(agent.id);
|
||||
const health = getHealthStatus(agent);
|
||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||
const stateNodeClass = getStateCardClass("agent-tree__node", agent.state);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`${stateNodeClass}${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 employees"}
|
||||
aria-label={childCount > 0 ? (expanded ? "Collapse" : "Expand") : "No employees"}
|
||||
>
|
||||
{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 ${stateBadgeClass}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
{/* Tree view: up to 1 skill badge */}
|
||||
{(() => {
|
||||
const skills = getSkillBadges(agent);
|
||||
if (skills.length === 0) return null;
|
||||
return (
|
||||
<span className="agent-tree__skill" title={skills.join(", ")}>
|
||||
{skills[0]}{skills.length > 1 && ` +${skills.length - 1}`}
|
||||
</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}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgChartNode({
|
||||
node,
|
||||
onSelect,
|
||||
@@ -272,10 +173,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
|
||||
const [agentView, setAgentView] = useState<"list" | "board" | "org">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
return (saved === "list" || saved === "board" || saved === "tree" || saved === "org") ? saved : "list";
|
||||
return (saved === "list" || saved === "board" || saved === "org") ? saved : "list";
|
||||
});
|
||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
@@ -287,7 +188,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
if (saved === "list" || saved === "board" || saved === "tree" || saved === "org") {
|
||||
if (saved === "list" || saved === "board" || saved === "org") {
|
||||
setAgentView(saved);
|
||||
return;
|
||||
}
|
||||
@@ -363,7 +264,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
});
|
||||
}, [agents, optimisticStateOverrides]);
|
||||
|
||||
const hierarchy = useAgentHierarchy(optimisticAgents, projectId);
|
||||
|
||||
// Filter agents for display. "All States" means all non-ephemeral agents,
|
||||
// including disabled/terminated agents that still carry configuration.
|
||||
@@ -754,15 +654,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
>
|
||||
<Activity 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>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("org")}
|
||||
@@ -956,26 +847,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading agents...</span>
|
||||
</div>
|
||||
) : agentView === "tree" ? (
|
||||
<div className="agent-tree__view">
|
||||
{displayAgents.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
) : (
|
||||
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}
|
||||
getSkillBadges={getSkillBadges}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : agentView === "org" ? (
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
|
||||
@@ -378,11 +378,6 @@ describe("AgentsView", () => {
|
||||
expect(document.querySelector(`.agent-board-card--${state}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tree view" }));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(`.agent-tree__node--${state}`)).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector(`.org-chart-node-card--${state}`)).toBeTruthy();
|
||||
|
||||
@@ -169,12 +169,12 @@ describe("AgentsView mobile adaptations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("switches between board, list, and tree views", async () => {
|
||||
it("switches between board, list, and org views", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Tree view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-tree__view")).toBeTruthy());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-org-chart")).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
|
||||
await waitFor(() => expect(container.querySelector(".agent-board")).toBeTruthy());
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
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";
|
||||
import { scopedKey } from "../../utils/projectStorage";
|
||||
|
||||
// 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);
|
||||
|
||||
const PROJECT_ID = "proj-123";
|
||||
const EXPANDED_STORAGE_KEY = scopedKey("fn-agent-tree-expanded", PROJECT_ID);
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function renderHierarchy(agents: Agent[], projectId: string | undefined = PROJECT_ID) {
|
||||
return renderHook(() => useAgentHierarchy(agents, projectId));
|
||||
}
|
||||
|
||||
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 } = renderHierarchy([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 } = renderHierarchy([]);
|
||||
|
||||
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 } = renderHierarchy([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 === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent", "child"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const { result } = renderHierarchy([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 } = renderHierarchy([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 } = renderHierarchy([parent]);
|
||||
|
||||
act(() => {
|
||||
result.current.toggleExpand("parent-1");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
EXPANDED_STORAGE_KEY,
|
||||
JSON.stringify(["parent-1"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("restores expand state from localStorage on mount", () => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === EXPANDED_STORAGE_KEY) return JSON.stringify(["parent-1"]);
|
||||
return localStorageStore[key] ?? null;
|
||||
});
|
||||
|
||||
const parent = createMockAgent({ id: "parent-1", name: "Parent" });
|
||||
|
||||
const { result } = renderHierarchy([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 } = renderHierarchy([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 } = renderHierarchy([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 } = renderHierarchy([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");
|
||||
});
|
||||
});
|
||||
@@ -1,116 +0,0 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import type { Agent } from "../api";
|
||||
import { getScopedItem, setScopedItem } from "../utils/projectStorage";
|
||||
|
||||
const EXPANDED_KEY = "fn-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(projectId?: string): Set<string> {
|
||||
try {
|
||||
const stored = getScopedItem(EXPANDED_KEY, projectId);
|
||||
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>, projectId?: string): void {
|
||||
try {
|
||||
setScopedItem(EXPANDED_KEY, JSON.stringify([...expanded]), projectId);
|
||||
} 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 (manager-employee relationships).
|
||||
* Derives the tree structure from the `reportsTo` field on agents.
|
||||
* Expand/collapse state is persisted to localStorage.
|
||||
*/
|
||||
export function useAgentHierarchy(agents: Agent[], projectId?: string): UseAgentHierarchyReturn {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => readExpandedFromStorage(projectId));
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded(readExpandedFromStorage(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
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, projectId);
|
||||
return next;
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
@@ -82,7 +82,6 @@ describe("projectStorage", () => {
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
"fn-agent-tree-expanded",
|
||||
"kb-terminal-tabs",
|
||||
"kb-planning-last-description",
|
||||
"kb-subtask-last-description",
|
||||
@@ -93,7 +92,7 @@ describe("projectStorage", () => {
|
||||
"kb-chat-active-session",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(17);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(16);
|
||||
});
|
||||
|
||||
it("has no overlap between global and project-scoped keys", () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
"fn-agent-tree-expanded",
|
||||
"kb-terminal-tabs",
|
||||
"kb-planning-last-description",
|
||||
"kb-subtask-last-description",
|
||||
|
||||
Reference in New Issue
Block a user