feat(FN-2378): add popup controls and reorder agents view sections
- Move secondary agent sections below the collection area to support the agents-first layout - Add a popup controls panel in AgentsView with updated styling and responsive behavior - Expand dashboard tests to cover popup controls, CSS classes, and the new mobile/section ordering - Resolve engine logger lint issues and update agents documentation/README for the new layout
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
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 { useState, useEffect, useCallback, useRef, useMemo, useId } from "react";
|
||||
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, GitBranch, Filter, Upload, Network, SlidersHorizontal } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
@@ -265,6 +265,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
});
|
||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const controlsPanelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
@@ -403,6 +407,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
};
|
||||
}, [loadAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isControlsPanelOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent | TouchEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (!target) return;
|
||||
if (controlsPanelRef.current?.contains(target)) return;
|
||||
if (controlsTriggerRef.current?.contains(target)) return;
|
||||
setIsControlsPanelOpen(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
setIsControlsPanelOpen(false);
|
||||
controlsTriggerRef.current?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("touchstart", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("touchstart", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isControlsPanelOpen]);
|
||||
|
||||
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||
if (transitioningAgentIds.has(agentId)) return;
|
||||
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
|
||||
@@ -675,116 +707,145 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
<Network size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agents-view-content">
|
||||
{/* Filter and Create Bar */}
|
||||
<div className="agent-controls">
|
||||
<div className="agent-controls-filters">
|
||||
<div className="agent-state-filter">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
className="agent-state-filter-select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
aria-label="Filter agents by state"
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label agent-system-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSystemAgents}
|
||||
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
||||
aria-label="Show system agents"
|
||||
/>
|
||||
Show system agents
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="agent-controls-actions">
|
||||
<div className="agents-view-primary-actions">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => setIsImporting(true)}
|
||||
ref={controlsTriggerRef}
|
||||
className={`btn agent-controls-trigger${isControlsPanelOpen ? " agent-controls-trigger--active" : ""}`}
|
||||
onClick={() => setIsControlsPanelOpen((open) => !open)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={isControlsPanelOpen}
|
||||
aria-controls={controlsPanelId}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Import
|
||||
<SlidersHorizontal size={16} />
|
||||
Controls
|
||||
</button>
|
||||
<button
|
||||
className="btn btn--primary"
|
||||
onClick={() => setIsCreating(true)}
|
||||
onClick={() => {
|
||||
setIsCreating(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
>
|
||||
<Plus size={16} />
|
||||
New Agent
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => void loadAgents()}
|
||||
title="Refresh"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Heartbeat Speed Control */}
|
||||
<div className="agent-global-controls">
|
||||
<div className="heartbeat-multiplier-group">
|
||||
<div className="heartbeat-multiplier-controls">
|
||||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
||||
Heartbeat Speed
|
||||
{isControlsPanelOpen && (
|
||||
<div
|
||||
ref={controlsPanelRef}
|
||||
id={controlsPanelId}
|
||||
className="agent-controls-panel"
|
||||
role="dialog"
|
||||
aria-label="Agent controls"
|
||||
aria-modal="false"
|
||||
>
|
||||
<div className="agent-controls">
|
||||
<div className="agent-controls-filters">
|
||||
<div className="agent-state-filter">
|
||||
<Filter size={14} />
|
||||
<select
|
||||
className="agent-state-filter-select"
|
||||
value={filterState}
|
||||
onChange={(e) => setFilterState(e.target.value as AgentState | "all")}
|
||||
aria-label="Filter agents by state"
|
||||
>
|
||||
<option value="all">All States</option>
|
||||
<option value="idle">Idle</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="running">Running</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="terminated">Terminated</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<label className="checkbox-label agent-system-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showSystemAgents}
|
||||
onChange={(e) => setShowSystemAgents(e.target.checked)}
|
||||
aria-label="Show system agents"
|
||||
/>
|
||||
Show system agents
|
||||
</label>
|
||||
<input
|
||||
id="globalHeartbeatMultiplier"
|
||||
className="heartbeat-multiplier-slider touch-target"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={heartbeatMultiplier}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||
}}
|
||||
disabled={isSavingMultiplier}
|
||||
/>
|
||||
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
|
||||
<select
|
||||
className="heartbeat-multiplier-preset"
|
||||
value={String(
|
||||
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
||||
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
|
||||
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||
}}
|
||||
disabled={isSavingMultiplier}
|
||||
aria-label="Heartbeat speed preset"
|
||||
>
|
||||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||||
<option key={multiplier} value={String(multiplier)}>
|
||||
×{multiplier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<small className="text-secondary">
|
||||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
||||
</small>
|
||||
|
||||
<div className="agent-controls-actions">
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setIsImporting(true);
|
||||
setIsControlsPanelOpen(false);
|
||||
}}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Import
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agent-global-controls">
|
||||
<div className="heartbeat-multiplier-group">
|
||||
<div className="heartbeat-multiplier-controls">
|
||||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
||||
Heartbeat Speed
|
||||
</label>
|
||||
<input
|
||||
id="globalHeartbeatMultiplier"
|
||||
className="heartbeat-multiplier-slider touch-target"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={heartbeatMultiplier}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||
}}
|
||||
disabled={isSavingMultiplier}
|
||||
/>
|
||||
<span className="heartbeat-multiplier-value">×{heartbeatMultiplier.toFixed(1)}</span>
|
||||
<select
|
||||
className="heartbeat-multiplier-preset"
|
||||
value={String(
|
||||
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
||||
return Math.abs(candidate - heartbeatMultiplier) < Math.abs(closest - heartbeatMultiplier) ? candidate : closest;
|
||||
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
void handleHeartbeatMultiplierChange(Number.isFinite(val) && val > 0 ? val : 1);
|
||||
}}
|
||||
disabled={isSavingMultiplier}
|
||||
aria-label="Heartbeat speed preset"
|
||||
>
|
||||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||||
<option key={multiplier} value={String(multiplier)}>
|
||||
×{multiplier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<small className="text-secondary">
|
||||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="agents-view-content">
|
||||
|
||||
<NewAgentDialog
|
||||
isOpen={isCreating}
|
||||
@@ -800,13 +861,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
{/* Metrics Bar */}
|
||||
<AgentMetricsBar stats={stats} />
|
||||
|
||||
{/* Active Agents Panel - Live streaming cards */}
|
||||
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||
|
||||
{/* Agent List */}
|
||||
{/* Agent Collection */}
|
||||
{agentView === "tree" ? (
|
||||
<div className="agent-tree__view">
|
||||
{displayAgents.length === 0 ? (
|
||||
@@ -1205,6 +1260,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Secondary sections */}
|
||||
<AgentMetricsBar stats={stats} />
|
||||
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||
</div>
|
||||
|
||||
{/* Agent Detail Modal */}
|
||||
|
||||
@@ -103,6 +103,15 @@ describe("AgentsView", () => {
|
||||
mockUpdateSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
const openControlsPanel = async () => {
|
||||
const trigger = await screen.findByRole("button", { name: "Controls" });
|
||||
fireEvent.click(trigger);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy();
|
||||
});
|
||||
return trigger;
|
||||
};
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the agents view header", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
@@ -120,6 +129,58 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps New Agent directly accessible while controls live in popup", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "New Agent" })).toBeTruthy();
|
||||
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
|
||||
|
||||
await openControlsPanel();
|
||||
expect(screen.getByLabelText("Filter agents by state")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Import" })).toBeTruthy();
|
||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes controls popup on Escape and outside click", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
const trigger = await openControlsPanel();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
|
||||
});
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(trigger);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.mouseDown(document.body);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("dialog", { name: "Agent controls" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders secondary sections after the main collection", async () => {
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agent-list")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-metrics-bar")).toBeTruthy();
|
||||
expect(container.querySelector(".active-agents-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
const list = container.querySelector(".agent-list");
|
||||
const metrics = container.querySelector(".agent-metrics-bar");
|
||||
const activePanel = container.querySelector(".active-agents-panel");
|
||||
expect(list && metrics && activePanel).toBeTruthy();
|
||||
expect(list!.compareDocumentPosition(metrics!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(metrics!.compareDocumentPosition(activePanel!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
@@ -173,13 +234,10 @@ describe("AgentsView", () => {
|
||||
|
||||
it("shows terminated agents when explicitly filtered", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Switch to terminated filter
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -740,10 +798,7 @@ describe("AgentsView", () => {
|
||||
describe("filter agents by state", () => {
|
||||
it("renders the state filter with styled container", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Styled filter container exists
|
||||
const filterContainer = document.querySelector(".agent-state-filter");
|
||||
@@ -756,12 +811,9 @@ describe("AgentsView", () => {
|
||||
|
||||
it("can filter agents by state", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await openControlsPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -771,12 +823,9 @@ describe("AgentsView", () => {
|
||||
|
||||
it("clears filter when selecting 'all'", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await openControlsPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -794,10 +843,7 @@ describe("AgentsView", () => {
|
||||
describe("show system agents toggle", () => {
|
||||
it("renders the system agents checkbox", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Checkbox should be unchecked by default
|
||||
const checkbox = screen.getByLabelText("Show system agents") as HTMLInputElement;
|
||||
@@ -807,10 +853,6 @@ describe("AgentsView", () => {
|
||||
it("passes includeEphemeral: false by default to fetchAgents", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Default call should include includeEphemeral: false
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: false }, undefined);
|
||||
@@ -819,10 +861,7 @@ describe("AgentsView", () => {
|
||||
|
||||
it("toggles system agents visibility when checkbox is clicked", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
const checkbox = screen.getByLabelText("Show system agents");
|
||||
fireEvent.click(checkbox);
|
||||
@@ -834,10 +873,7 @@ describe("AgentsView", () => {
|
||||
|
||||
it("combines system agents toggle with state filter", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// First enable system agents toggle
|
||||
const checkbox = screen.getByLabelText("Show system agents");
|
||||
@@ -848,7 +884,7 @@ describe("AgentsView", () => {
|
||||
});
|
||||
|
||||
// Then filter by state
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -881,6 +917,7 @@ describe("AgentsView", () => {
|
||||
|
||||
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
|
||||
|
||||
await openControlsPanel();
|
||||
const checkbox = screen.getByLabelText("Show system agents");
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
@@ -1184,13 +1221,10 @@ describe("AgentsView", () => {
|
||||
|
||||
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Switch to terminated filter
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1223,13 +1257,10 @@ describe("AgentsView", () => {
|
||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Switch to terminated filter to see terminated agent
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1257,13 +1288,10 @@ describe("AgentsView", () => {
|
||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("All States")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Switch to terminated filter to see terminated agent
|
||||
const filterSelect = screen.getByDisplayValue("All States");
|
||||
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -1493,10 +1521,7 @@ describe("AgentsView", () => {
|
||||
it("renders the global heartbeat speed control", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Check the slider and preset are rendered
|
||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||
@@ -1509,20 +1534,16 @@ describe("AgentsView", () => {
|
||||
it("loads heartbeat multiplier from settings", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 2.5 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
await openControlsPanel();
|
||||
|
||||
await waitFor(() => {
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" }) as HTMLInputElement;
|
||||
expect(slider.value).toBe("2.5");
|
||||
});
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" }) as HTMLInputElement;
|
||||
expect(slider.value).toBe("2.5");
|
||||
});
|
||||
|
||||
it("saves heartbeat multiplier when slider changes", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Change the slider
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
||||
@@ -1537,10 +1558,7 @@ describe("AgentsView", () => {
|
||||
it("saves heartbeat multiplier when preset is selected", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Change the preset
|
||||
const preset = screen.getByLabelText("Heartbeat speed preset") as HTMLSelectElement;
|
||||
@@ -1555,10 +1573,7 @@ describe("AgentsView", () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
mockUpdateSettings.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
await openControlsPanel();
|
||||
|
||||
// Change the slider - this should start the save
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
||||
|
||||
@@ -136,14 +136,22 @@ describe("AgentsView mobile adaptations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders agent controls, filter, and action buttons", async () => {
|
||||
it("renders controls trigger and reveals panel controls on demand", async () => {
|
||||
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||
|
||||
expect(container.querySelector(".agent-controls")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls-filters")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-state-filter")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls-actions")).toBeTruthy();
|
||||
const controlsTrigger = screen.getByRole("button", { name: "Controls" });
|
||||
expect(controlsTrigger.getAttribute("aria-expanded")).toBe("false");
|
||||
|
||||
fireEvent.click(controlsTrigger);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("dialog", { name: "Agent controls" })).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls-filters")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-state-filter")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-controls-actions")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches between board, list, and tree views", async () => {
|
||||
@@ -162,6 +170,8 @@ describe("AgentsView mobile adaptations", () => {
|
||||
|
||||
it("renders state filter select with expected options", async () => {
|
||||
render(<AgentsView addToast={vi.fn()} />);
|
||||
const controlsTrigger = await screen.findByRole("button", { name: "Controls" });
|
||||
fireEvent.click(controlsTrigger);
|
||||
await waitFor(() => expect(screen.getByLabelText("Filter agents by state")).toBeTruthy());
|
||||
|
||||
const select = screen.getByLabelText("Filter agents by state") as HTMLSelectElement;
|
||||
|
||||
Reference in New Issue
Block a user