feat(FN-1698): merge fusion/fn-1698

This commit is contained in:
gsxdsm
2026-04-13 16:05:12 -07:00
parent eb669a21b4
commit 4e5067f68d
5 changed files with 213 additions and 38 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { JSX } from "react";
import { X, Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, Filter } from "lucide-react";
import type { Agent, AgentCapability, AgentState } from "../api";
@@ -62,6 +62,15 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
const roleSelectRef = useRef<HTMLSelectElement>(null);
// Filter agents for display: hide terminated agents in default "All States" view
// but show them when the user explicitly filters to "terminated"
const displayAgents = useMemo(() => {
if (filterState === "all") {
return agents.filter(a => a.state !== "terminated");
}
return agents;
}, [agents, filterState]);
const loadAgents = useCallback(async () => {
setIsLoading(true);
try {
@@ -270,7 +279,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
{/* Agent List */}
<div className={view === "board" ? "agent-board" : "agent-list"}>
{agents.length === 0 ? (
{displayAgents.length === 0 ? (
<div className="agent-empty">
<Bot size={48} opacity={0.3} />
<p>No agents found</p>
@@ -278,7 +287,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
</div>
) : view === "board" ? (
// Board view: compact grid layout
agents.map(agent => {
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
return (
@@ -409,7 +418,7 @@ export function AgentListModal({ isOpen, onClose, addToast, projectId }: AgentLi
})
) : (
// List view: detailed card layout
agents.map(agent => {
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
return (

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import type { JSX } from "react";
import { Plus, Play, Pause, Square, Activity, Heart, Trash2, RefreshCw, Bot, LayoutGrid, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network } from "lucide-react";
import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
@@ -223,6 +223,35 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
const hierarchy = useAgentHierarchy(agents, projectId);
// Filter agents for display: hide terminated agents in default "All States" view
// but show them when the user explicitly filters to "terminated"
const displayAgents = useMemo(() => {
if (filterState === "all") {
return agents.filter(a => a.state !== "terminated");
}
return agents;
}, [agents, filterState]);
// Filter org tree to exclude terminated agents in default view
const displayOrgTree = useMemo(() => {
if (filterState === "all") {
// Recursively filter out terminated agents from the org tree
const filterNode = (node: OrgTreeNode): OrgTreeNode | null => {
if (node.agent.state === "terminated") return null;
return {
...node,
children: node.children
.map(filterNode)
.filter((n): n is OrgTreeNode => n !== null),
};
};
return orgTree
.map(filterNode)
.filter((n): n is OrgTreeNode => n !== null);
}
return orgTree;
}, [orgTree, filterState]);
const loadAgents = useCallback(async () => {
setIsLoading(true);
try {
@@ -501,7 +530,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
{/* Agent List */}
{agentView === "tree" ? (
<div className="agent-tree__view">
{agents.length === 0 ? (
{displayAgents.length === 0 ? (
<div className="agent-empty">
<Bot size={48} opacity={0.3} />
<p>No agents found</p>
@@ -529,14 +558,14 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
<RefreshCw size={18} className="spin" />
<span>Loading org chart...</span>
</div>
) : orgTree.length === 0 ? (
) : displayOrgTree.length === 0 ? (
<div className="agent-empty">
<Bot size={48} opacity={0.3} />
<p>No agents found</p>
<p className="text-secondary">Create an agent to get started</p>
</div>
) : (
orgTree.map((node) => (
displayOrgTree.map((node) => (
<OrgChartNode
key={node.agent.id}
node={node}
@@ -549,7 +578,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
</div>
) : (
<div className={agentView === "board" ? "agent-board" : "agent-list"}>
{agents.length === 0 ? (
{displayAgents.length === 0 ? (
<div className="agent-empty">
<Bot size={48} opacity={0.3} />
<p>No agents found</p>
@@ -557,7 +586,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
</div>
) : agentView === "board" ? (
// Board view: compact grid layout
agents.map(agent => {
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
return (
@@ -716,7 +745,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
})
) : (
// List view: detailed card layout
agents.map(agent => {
displayAgents.map(agent => {
const health = getHealthStatus(agent);
const stateStyle = STATE_COLORS[agent.state];
return (

View File

@@ -183,7 +183,31 @@ describe("AgentListModal", () => {
expect(screen.getByText("idle")).toBeTruthy();
expect(screen.getByText("active")).toBeTruthy();
expect(screen.getByText("paused")).toBeTruthy();
// Terminated agents are hidden in default "All States" view
expect(screen.queryByText("terminated")).toBeNull();
});
});
it("shows terminated agents when explicitly filtered", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("terminated")).toBeTruthy();
expect(screen.getByText("Test Agent 4")).toBeTruthy();
});
});
@@ -567,7 +591,7 @@ describe("AgentListModal", () => {
});
describe("agent deletion", () => {
it("shows Delete button for idle and terminated agents", async () => {
it("shows Delete button for idle agents in default view (terminated filtered out)", async () => {
render(
<AgentListModal
isOpen={true}
@@ -577,12 +601,40 @@ describe("AgentListModal", () => {
);
await waitFor(() => {
// Multiple delete buttons: one for idle (agent-001) and one for terminated (agent-004)
// In default "All States" view, only idle agent (agent-001) should have delete button
// Terminated agents (agent-004) are filtered out
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Verify Start button appears for terminated agent (agent-004)
// Verify terminated agent is not visible
expect(screen.queryByText("Test Agent 4")).toBeNull();
});
it("shows Delete button for terminated agents when explicitly filtered", async () => {
render(
<AgentListModal
isOpen={true}
onClose={mockOnClose}
addToast={mockAddToast}
/>
);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
// Now we should see the Delete button for terminated agent
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
});
// Verify Start button appears for terminated agent
const agentCards = document.querySelectorAll(".agent-card");
let terminatedCard: Element | null = null;
agentCards.forEach(card => {
@@ -592,7 +644,7 @@ describe("AgentListModal", () => {
expect(terminatedStartBtn).toBeTruthy();
});
it("confirms before deleting agent", async () => {
it("confirms before deleting terminated agent (from terminated filter)", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(
@@ -604,7 +656,16 @@ describe("AgentListModal", () => {
);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter to see terminated agent
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
});
// Find delete button for terminated agent (agent-004)
@@ -624,7 +685,7 @@ describe("AgentListModal", () => {
confirmSpy.mockRestore();
});
it("deletes agent after confirmation", async () => {
it("deletes terminated agent after confirmation (from terminated filter)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
render(
@@ -636,7 +697,15 @@ describe("AgentListModal", () => {
);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter to see terminated agent
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
});
// Find delete button for terminated agent (agent-004)
@@ -658,7 +727,7 @@ describe("AgentListModal", () => {
);
});
it("deletes idle agent after confirmation", async () => {
it("deletes idle agent after confirmation (from default view)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
render(
@@ -670,7 +739,10 @@ describe("AgentListModal", () => {
);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
// Only idle agent (agent-001) should have delete button in default view
// Terminated agent (agent-004) is filtered out
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Find delete button for idle agent (agent-001)
@@ -705,17 +777,19 @@ describe("AgentListModal", () => {
);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
// Only idle agent (agent-001) should have delete button in default view
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Click the first available delete button
// Click the idle agent's delete button
const agentCards = document.querySelectorAll(".agent-card");
let terminatedCard: Element | null = null;
let idleCard: Element | null = null;
agentCards.forEach(card => {
if (card.textContent?.includes("agent-004")) terminatedCard = card;
if (card.textContent?.includes("agent-001")) idleCard = card;
});
const terminatedDeleteBtn = terminatedCard?.querySelector('[title="Delete"]') as HTMLElement;
fireEvent.click(terminatedDeleteBtn);
const idleDeleteBtn = idleCard?.querySelector('[title="Delete"]') as HTMLElement;
fireEvent.click(idleDeleteBtn);
await waitFor(() => {
expect(mockAddToast).toHaveBeenCalledWith(
@@ -1059,8 +1133,9 @@ describe("AgentListModal", () => {
await waitFor(() => {
// Board view should render compact cards
// 4 agents total, but terminated (agent-004) is filtered out in default view
const boardCards = document.querySelectorAll(".agent-board-card");
expect(boardCards.length).toBe(mockAgents.length);
expect(boardCards.length).toBe(3);
});
// Check that board view elements are present
@@ -1122,8 +1197,9 @@ describe("AgentListModal", () => {
expect(listContainer).toBeTruthy();
// Detailed cards should be present
// 4 agents total, but terminated (agent-004) is filtered out in default view
const agentCards = document.querySelectorAll(".agent-card");
expect(agentCards.length).toBe(mockAgents.length);
expect(agentCards.length).toBe(3);
});
});
});

View File

@@ -139,6 +139,23 @@ describe("AgentsView", () => {
expect(screen.getAllByText("idle").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("active").length).toBeGreaterThanOrEqual(1);
expect(screen.getAllByText("paused").length).toBeGreaterThanOrEqual(1);
// Terminated agents are hidden in default "All States" view
expect(screen.queryAllByText("terminated").length).toBe(0);
});
});
it("shows terminated agents when explicitly filtered", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getAllByText("terminated").length).toBeGreaterThanOrEqual(1);
});
});
@@ -194,7 +211,8 @@ describe("AgentsView", () => {
await waitFor(() => {
const boardCards = document.querySelectorAll(".agent-board-card");
expect(boardCards.length).toBe(mockAgents.length);
// 4 agents total, but terminated (agent-004) is filtered out in default view
expect(boardCards.length).toBe(3);
});
});
@@ -659,13 +677,35 @@ describe("AgentsView", () => {
});
describe("delete agent", () => {
it("shows Delete button for idle and terminated agents", async () => {
it("shows Delete button for idle agents in default view (terminated filtered out)", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
// There should be multiple Delete buttons: one for idle (agent-001) and one for terminated (agent-004)
// In default "All States" view, only idle agent (agent-001) should show Delete button
// Terminated agents (agent-004) are filtered out
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(2);
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Verify terminated agent is not visible
expect(screen.queryByText("Test Agent 4")).toBeNull();
});
it("shows Delete button for terminated agents when explicitly filtered", async () => {
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
// Now we should see the Delete button for terminated agent
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(1);
});
});
@@ -688,12 +728,21 @@ describe("AgentsView", () => {
});
});
it("confirms before deleting agent", async () => {
it("confirms before deleting terminated agent (from terminated filter)", async () => {
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter to see terminated agent
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
// Click the delete button for the terminated agent (agent-004)
const agentCards = document.querySelectorAll(".agent-card");
let terminatedCard: Element | null = null;
@@ -713,13 +762,21 @@ describe("AgentsView", () => {
confirmSpy.mockRestore();
});
it("deletes agent after confirmation", async () => {
it("deletes terminated agent after confirmation (from terminated filter)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
expect(screen.getByText("All States")).toBeTruthy();
});
// Switch to terminated filter to see terminated agent
const filterSelect = screen.getByDisplayValue("All States");
fireEvent.change(filterSelect, { target: { value: "terminated" } });
await waitFor(() => {
expect(screen.getByText("Test Agent 4")).toBeTruthy();
});
// Find the delete button for terminated agent (agent-004)
@@ -741,13 +798,16 @@ describe("AgentsView", () => {
);
});
it("deletes idle agent after confirmation", async () => {
it("deletes idle agent after confirmation (from default view)", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => {
expect(screen.getAllByTitle("Delete").length).toBeGreaterThanOrEqual(2);
// Only idle agent (agent-001) should have delete button in default view
// Terminated agent (agent-004) is filtered out
const deleteButtons = screen.getAllByTitle("Delete");
expect(deleteButtons.length).toBeGreaterThanOrEqual(1);
});
// Find the delete button for idle agent (agent-001)