feat(FN-2387): unify agents view loading through useAgents
- Move AgentsView to consume agents, loading state, and reload logic directly from useAgents - Extend useAgents with filterState/showSystemAgents options and always pass includeEphemeral in fetch filters - Remove duplicate initial fetch/SSE path in AgentsView and rely on hook-managed refresh behavior - Add regression coverage for single initial load and system-agent visibility toggling behavior - Update useAgents hook tests to assert the new includeEphemeral fetch contract
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
import { AgentEmptyState } from "./AgentEmptyState";
|
||||
import { useAgents } from "../hooks/useAgents";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
import { useAgentHierarchy } from "../hooks/useAgentHierarchy";
|
||||
import type { AgentNode } from "../hooks/useAgentHierarchy";
|
||||
import { NewAgentDialog } from "./NewAgentDialog";
|
||||
@@ -250,12 +249,14 @@ function OrgChartNode({
|
||||
}
|
||||
|
||||
export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const { activeAgents, stats } = useAgents(projectId);
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const { agents, activeAgents, stats, isLoading, loadAgents } = useAgents(projectId, {
|
||||
filterState,
|
||||
showSystemAgents,
|
||||
});
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
@@ -281,7 +282,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
|
||||
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null);
|
||||
/** Agent ID currently showing custom heartbeat input */
|
||||
const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null);
|
||||
@@ -350,23 +350,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
.filter((n): n is OrgTreeNode => n !== null);
|
||||
}, [orgTree, showSystemAgents]);
|
||||
|
||||
const loadAgents = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const filter = filterState !== "all" ? { state: filterState } : undefined;
|
||||
const data = await fetchAgents({ ...filter, includeEphemeral: showSystemAgents }, projectId);
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
addToast(`Failed to load agents: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [filterState, showSystemAgents, addToast, projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAgents();
|
||||
}, [loadAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (agentView !== "org") return;
|
||||
|
||||
@@ -395,25 +378,9 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
};
|
||||
}, [agentView, projectId, showSystemAgents, addToast]);
|
||||
|
||||
// Refresh agent list on SSE events (independent from useAgents hook state)
|
||||
useEffect(() => {
|
||||
const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||
const refresh = () => {
|
||||
void loadAgents();
|
||||
};
|
||||
|
||||
return subscribeSse(`/api/events${query}`, {
|
||||
events: {
|
||||
"agent:created": refresh,
|
||||
"agent:updated": refresh,
|
||||
"agent:deleted": refresh,
|
||||
"agent:stateChanged": refresh,
|
||||
},
|
||||
});
|
||||
}, [projectId, loadAgents]);
|
||||
|
||||
// Poll for agent updates to keep health statuses fresh (every 30 seconds)
|
||||
// This ensures health badges stay current while the view is open
|
||||
// This ensures health badges stay current while the view is open.
|
||||
// SSE refreshes are handled by useAgents.
|
||||
useEffect(() => {
|
||||
const pollInterval = setInterval(() => {
|
||||
void loadAgents();
|
||||
@@ -426,8 +393,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
if (transitioningAgentIds.has(agentId)) return;
|
||||
const previousAgent = agents.find(a => a.id === agentId);
|
||||
setAgents(prev => prev.map(a => a.id === agentId ? { ...a, state: newState } : a));
|
||||
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
|
||||
try {
|
||||
await updateAgentState(agentId, newState, projectId);
|
||||
@@ -441,9 +406,6 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
}
|
||||
void loadAgents();
|
||||
} catch (err) {
|
||||
if (previousAgent) {
|
||||
setAgents(prev => prev.map(a => a.id === agentId ? previousAgent : a));
|
||||
}
|
||||
addToast(`Failed to update state: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
setTransitioningAgentIds(prev => { const next = new Set(prev); next.delete(agentId); return next; });
|
||||
|
||||
@@ -120,11 +120,16 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("fetches agents on mount", async () => {
|
||||
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalled();
|
||||
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Ensure the single-load path still powers dependent UI sections.
|
||||
expect(screen.getByText("Active Agents (1)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("passes projectId to agent fetches", async () => {
|
||||
@@ -851,21 +856,22 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows system agents in agent list when checkbox is enabled", async () => {
|
||||
it("hides system agents by default and reveals them when Show system agents is enabled", async () => {
|
||||
const systemAgents: Agent[] = [
|
||||
{
|
||||
id: "agent-sys-001",
|
||||
name: "executor-FN-TEST",
|
||||
role: "executor" as AgentCapability,
|
||||
state: "terminated" as AgentState,
|
||||
state: "active" as AgentState,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
metadata: { agentKind: "task-worker" },
|
||||
},
|
||||
];
|
||||
|
||||
// Mock returns only normal agents by default (excluding terminated)
|
||||
mockFetchAgents.mockResolvedValue(mockAgents.slice(0, 3));
|
||||
// Return system agent even when includeEphemeral is false to verify
|
||||
// client-side filtering still hides it unless the toggle is enabled.
|
||||
mockFetchAgents.mockResolvedValue([...mockAgents.slice(0, 3), ...systemAgents]);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
@@ -873,20 +879,14 @@ describe("AgentsView", () => {
|
||||
expect(screen.getByText("Test Agent 1")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Normal agents should be visible
|
||||
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
|
||||
|
||||
// Update mock to return system agents too (next call)
|
||||
mockFetchAgents.mockResolvedValueOnce([...mockAgents.slice(0, 3), ...systemAgents]);
|
||||
|
||||
// Enable system agents toggle
|
||||
const checkbox = screen.getByLabelText("Show system agents");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Now the agents should be reloaded with system agents included
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ includeEphemeral: true }, projectId);
|
||||
expect(screen.getByText("executor-FN-TEST")).toBeTruthy();
|
||||
expect(screen.getAllByText("executor-FN-TEST").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,7 +115,7 @@ describe("useAgents", () => {
|
||||
await result.current.loadAgents({ state: "active", role: "executor" });
|
||||
});
|
||||
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor" }, undefined);
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor", includeEphemeral: false }, undefined);
|
||||
});
|
||||
|
||||
it("handles fetchAgents rejection gracefully", async () => {
|
||||
@@ -196,8 +196,7 @@ describe("useAgents", () => {
|
||||
renderHook(() => useAgents(projectId));
|
||||
|
||||
await waitFor(() => {
|
||||
// fetchAgents defaults to excluding ephemeral agents (handled by API)
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId);
|
||||
expect(mockFetchAgents).toHaveBeenCalledWith({ includeEphemeral: false }, projectId);
|
||||
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
|
||||
|
||||
@@ -4,23 +4,43 @@ import { fetchAgents, fetchAgentStats } from "../api";
|
||||
import { isEphemeralAgent } from "@fusion/core";
|
||||
import { subscribeSse } from "../sse-bus";
|
||||
|
||||
export function useAgents(projectId?: string) {
|
||||
interface UseAgentsOptions {
|
||||
filterState?: AgentState | "all";
|
||||
showSystemAgents?: boolean;
|
||||
}
|
||||
|
||||
interface AgentFilter {
|
||||
state?: AgentState;
|
||||
role?: AgentCapability;
|
||||
includeEphemeral?: boolean;
|
||||
}
|
||||
|
||||
export function useAgents(projectId?: string, options?: UseAgentsOptions) {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [stats, setStats] = useState<AgentStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const loadAgents = useCallback(async (filter?: { state?: AgentState; role?: AgentCapability }) => {
|
||||
const loadAgents = useCallback(async (filter?: AgentFilter) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// By default, fetchAgents excludes ephemeral agents (handled by API)
|
||||
const data = await fetchAgents(filter, projectId);
|
||||
const filterState = options?.filterState;
|
||||
const baseFilter = filterState && filterState !== "all" ? { state: filterState } : undefined;
|
||||
const includeEphemeral = options?.showSystemAgents ?? false;
|
||||
const data = await fetchAgents(
|
||||
{
|
||||
...baseFilter,
|
||||
...filter,
|
||||
includeEphemeral: filter?.includeEphemeral ?? includeEphemeral,
|
||||
},
|
||||
projectId,
|
||||
);
|
||||
setAgents(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load agents:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
}, [projectId, options?.filterState, options?.showSystemAgents]);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
try {
|
||||
@@ -54,9 +74,13 @@ export function useAgents(projectId?: string) {
|
||||
});
|
||||
}, [projectId, loadAgents, loadStats]);
|
||||
|
||||
const activeAgents = agents.filter(a =>
|
||||
(a.state === "active" || a.state === "running") && !isEphemeralAgent(a)
|
||||
);
|
||||
const showSystemAgents = options?.showSystemAgents ?? false;
|
||||
const activeAgents = agents.filter((agent) => {
|
||||
if (agent.state !== "active" && agent.state !== "running") {
|
||||
return false;
|
||||
}
|
||||
return showSystemAgents || !isEphemeralAgent(agent);
|
||||
});
|
||||
|
||||
return { agents, activeAgents, stats, isLoading, loadAgents, loadStats };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user