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:
Fusion
2026-05-02 00:28:19 -07:00
committed by gsxdsm
parent 8260320255
commit 8f72eeed9e
10 changed files with 16 additions and 565 deletions

View 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.

View File

@@ -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", () => {

View File

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

View File

@@ -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 ? (

View File

@@ -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();

View File

@@ -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());

View File

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

View File

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

View File

@@ -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", () => {

View File

@@ -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",