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

@@ -229,6 +229,7 @@ Manage AI agents with a dedicated control surface accessible from the main dashb
**Features**:
- **State Filter**: Styled dropdown to filter agents by state (All States, Idle, Active, Paused, Terminated) with Filter icon, aria-label, and consistent dashboard styling using design tokens (`--radius-sm`, `--border`, `--bg`, `--focus-ring`)
- **Terminated Agent Filtering**: By default ("All States" filter), terminated agents are automatically hidden from the agent list to reduce clutter from frequently-terminating runtime task-worker agents. Terminated agents remain accessible by explicitly selecting the "Terminated" filter option, enabling intentional inspection and cleanup when needed. This behavior applies to both the main AgentsView and the AgentListModal.
- **View Modes**: Board (compact grid) and list (detailed card) layouts, persisted to localStorage
- **Agent CRUD**: Create agents with name and role (create form's text input and role/type select both use tokenized styling — `var(--surface)`, `var(--text)`, `var(--border)`, `var(--radius-sm)`, `var(--focus-ring)` — for consistent theme-aware rendering across all color themes and light/dark modes), change state, update roles inline, delete idle and terminated agents (active and paused agents must be stopped/terminated first)
- **Health Monitoring**: Heartbeat-based health status (Healthy, Unresponsive, Starting, Paused, Terminated) using CSS variable references for theme consistency

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)