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:
Fusion
2026-04-24 02:28:48 -07:00
committed by gsxdsm
parent 6c9f78a451
commit fa0cbe2327
6 changed files with 70 additions and 73 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -4608,6 +4608,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
// Check worktree existence asynchronously to avoid blocking event loop
if (!task.worktree) {
res.json([]);
@@ -17427,7 +17431,8 @@ async function persistImportedSkills(
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
throw notFound("Task not found");
res.status(404).json({ error: "Task not found" });
return;
}
// Done tasks: diff from the squash commit's first parent.
@@ -17645,6 +17650,10 @@ async function persistImportedSkills(
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
// Done tasks: diff from the squash commit's first parent.
// The merger only performs squash merges, so sha^..sha contains exactly