feat(FN-1698): merge fusion/fn-1698
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user