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:
Fusion
2026-04-24 13:34:23 -07:00
committed by gsxdsm
parent 85ba1071bd
commit 308ac21141
7 changed files with 348 additions and 183 deletions

View File

@@ -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 */}

View File

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

View File

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