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)");
|
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", () => {
|
it("should use dashboard tokens in the updated org chart styles", () => {
|
||||||
const agentsViewCss = fs.readFileSync(path.join(__dirname, "../components/AgentsView.css"), "utf-8");
|
const agentsViewCss = fs.readFileSync(path.join(__dirname, "../components/AgentsView.css"), "utf-8");
|
||||||
const orgChartStart = agentsViewCss.indexOf("/* === FN-1167: Agent Org Chart + Chain of Command === */");
|
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", () => {
|
it("should only keep runtime health color inline styles in AgentsView", () => {
|
||||||
const inlineStyleCount = (agentsViewContent.match(/style=\{\{/g) || []).length;
|
const inlineStyleCount = (agentsViewContent.match(/style=\{\{/g) || []).length;
|
||||||
expect(inlineStyleCount).toBe(4);
|
expect(inlineStyleCount).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should not have inline <style> blocks in AgentDetailView", () => {
|
it("should not have inline <style> blocks in AgentDetailView", () => {
|
||||||
|
|||||||
@@ -235,14 +235,7 @@
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Agent Tree View ────────────────────────────────────────────────────── */
|
/* ── Agent Tree Node Styles (shared with AgentDetailView) ──────────────── */
|
||||||
|
|
||||||
.agent-tree__view {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: var(--space-sm) 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-tree__node {
|
.agent-tree__node {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -251,62 +244,6 @@
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: var(--space-sm) var(--space-md);
|
padding: var(--space-sm) var(--space-md);
|
||||||
border-radius: var(--radius-sm);
|
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 {
|
.agent-tree__icon {
|
||||||
@@ -332,17 +269,6 @@
|
|||||||
flex-shrink: 0;
|
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 {
|
.agent-is-child {
|
||||||
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
background: color-mix(in srgb, var(--accent) 5%, transparent);
|
||||||
border-left: 2px solid var(--accent);
|
border-left: 2px solid var(--accent);
|
||||||
@@ -1025,9 +951,4 @@
|
|||||||
align-items: center;
|
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 "./AgentsView.css";
|
||||||
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
|
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 type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } 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 { AgentEmptyState } from "./AgentEmptyState";
|
||||||
import { useAgents } from "../hooks/useAgents";
|
import { useAgents } from "../hooks/useAgents";
|
||||||
import { useConfirm } from "../hooks/useConfirm";
|
import { useConfirm } from "../hooks/useConfirm";
|
||||||
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
|
||||||
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
|
||||||
import { NewAgentDialog } from "./NewAgentDialog";
|
import { NewAgentDialog } from "./NewAgentDialog";
|
||||||
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
import { ExperimentalAgentOnboardingModal } from "./ExperimentalAgentOnboardingModal";
|
||||||
import { AgentImportModal } from "./AgentImportModal";
|
import { AgentImportModal } from "./AgentImportModal";
|
||||||
@@ -67,7 +65,7 @@ function getStateBadgeClass(state: AgentState): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStateCardClass(
|
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,
|
state: AgentState,
|
||||||
): string {
|
): string {
|
||||||
switch (state) {
|
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({
|
function OrgChartNode({
|
||||||
node,
|
node,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -272,10 +173,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
|
const [onboardingDraft, setOnboardingDraft] = useState<AgentOnboardingSummary | null>(null);
|
||||||
const [isImporting, setIsImporting] = useState(false);
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
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";
|
if (typeof window === "undefined") return "list";
|
||||||
const saved = getScopedItem("fn-agent-view", projectId);
|
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 [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||||
@@ -287,7 +188,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = getScopedItem("fn-agent-view", projectId);
|
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);
|
setAgentView(saved);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -363,7 +264,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
});
|
});
|
||||||
}, [agents, optimisticStateOverrides]);
|
}, [agents, optimisticStateOverrides]);
|
||||||
|
|
||||||
const hierarchy = useAgentHierarchy(optimisticAgents, projectId);
|
|
||||||
|
|
||||||
// Filter agents for display. "All States" means all non-ephemeral agents,
|
// Filter agents for display. "All States" means all non-ephemeral agents,
|
||||||
// including disabled/terminated agents that still carry configuration.
|
// including disabled/terminated agents that still carry configuration.
|
||||||
@@ -754,15 +654,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
>
|
>
|
||||||
<Activity size={16} />
|
<Activity 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>
|
|
||||||
<button
|
<button
|
||||||
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
className={`view-toggle-btn${agentView === "org" ? " active" : ""}`}
|
||||||
onClick={() => setAgentView("org")}
|
onClick={() => setAgentView("org")}
|
||||||
@@ -956,26 +847,6 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
|||||||
<RefreshCw size={18} className="spin" />
|
<RefreshCw size={18} className="spin" />
|
||||||
<span>Loading agents...</span>
|
<span>Loading agents...</span>
|
||||||
</div>
|
</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" ? (
|
) : agentView === "org" ? (
|
||||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||||
{isOrgTreeLoading ? (
|
{isOrgTreeLoading ? (
|
||||||
|
|||||||
@@ -378,11 +378,6 @@ describe("AgentsView", () => {
|
|||||||
expect(document.querySelector(`.agent-board-card--${state}`)).toBeTruthy();
|
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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.querySelector(`.org-chart-node-card--${state}`)).toBeTruthy();
|
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()} />);
|
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Tree view" }));
|
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||||
await waitFor(() => expect(container.querySelector(".agent-tree__view")).toBeTruthy());
|
await waitFor(() => expect(container.querySelector(".agent-org-chart")).toBeTruthy());
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
|
fireEvent.click(screen.getByRole("button", { name: "Board view" }));
|
||||||
await waitFor(() => expect(container.querySelector(".agent-board")).toBeTruthy());
|
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-quick-entry-text",
|
||||||
"kb-inline-create-text",
|
"kb-inline-create-text",
|
||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"fn-agent-tree-expanded",
|
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
"kb-subtask-last-description",
|
"kb-subtask-last-description",
|
||||||
@@ -93,7 +92,7 @@ describe("projectStorage", () => {
|
|||||||
"kb-chat-active-session",
|
"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", () => {
|
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-quick-entry-text",
|
||||||
"kb-inline-create-text",
|
"kb-inline-create-text",
|
||||||
"fn-agent-view",
|
"fn-agent-view",
|
||||||
"fn-agent-tree-expanded",
|
|
||||||
"kb-terminal-tabs",
|
"kb-terminal-tabs",
|
||||||
"kb-planning-last-description",
|
"kb-planning-last-description",
|
||||||
"kb-subtask-last-description",
|
"kb-subtask-last-description",
|
||||||
|
|||||||
Reference in New Issue
Block a user