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:
@@ -86,9 +86,10 @@ These fields can only be set during update (not on create):
|
||||
|
||||
The agents surface provides:
|
||||
|
||||
- Agent list and status
|
||||
- Agent-first list/board/tree/org collection (primary content appears first)
|
||||
- A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed)
|
||||
- Detail/config panels
|
||||
- Runtime metrics
|
||||
- Runtime metrics and active-agent live cards rendered below the main collection
|
||||
- Run history
|
||||
- Task assignment context
|
||||
|
||||
@@ -143,7 +144,7 @@ The `runtimeConfig` field on agents supports the following options:
|
||||
| `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings |
|
||||
|
||||
Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms).
|
||||
Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat intervals globally; per-agent `heartbeatIntervalMs` remains the base interval before multiplier scaling. This setting is configured from the **Agents** screen's "Heartbeat Speed" control.
|
||||
Project setting `heartbeatMultiplier` (default `1`) scales resolved heartbeat intervals globally; per-agent `heartbeatIntervalMs` remains the base interval before multiplier scaling. This setting is configured from the **Agents** screen's **Controls** popup under "Heartbeat Speed".
|
||||
|
||||
## Agent Instructions (Dashboard)
|
||||
|
||||
|
||||
@@ -179,7 +179,9 @@ In addition to the global mobile foundation, several power-user surfaces now inc
|
||||
|
||||
- **AgentsView**
|
||||
- Board mode collapses to a single column (`.agent-board { grid-template-columns: 1fr; }`).
|
||||
- Controls stack vertically (`.agent-controls` + `.agent-controls-actions`) with full-width action buttons and touch-friendly sizing.
|
||||
- Main agent collection remains the first content block; secondary metrics/live panels remain below it.
|
||||
- Import/filter/global heartbeat controls live in a dismissible `Controls` popup (`.agent-controls-panel`) that stays reachable on mobile.
|
||||
- Controls inside the popup stack vertically (`.agent-controls` + `.agent-controls-actions`) with full-width touch-friendly sizing.
|
||||
- State filter stretches full width on mobile.
|
||||
- Tree-view indentation is reduced (`.agent-tree__indent--1..4`) to prevent horizontal overflow at deeper hierarchy levels.
|
||||
|
||||
@@ -229,6 +231,8 @@ When `taskStuckTimeoutMs` is configured in project settings, stuck tasks are vis
|
||||
Manage AI agents with a dedicated control surface accessible from the main dashboard navigation. All agent surfaces (AgentsView, AgentListModal, AgentDetailView) share consistent token-based styling using dashboard design tokens (`--surface`, `--card`, `--border`, `--text`, `--color-success`, `--color-error`, etc.) and locally defined state color tokens (`--state-idle-*`, `--state-active-*`, `--state-paused-*`, `--state-error-*`) for theme-aware rendering.
|
||||
|
||||
**Features**:
|
||||
- **Agent-first layout**: The main agent collection (list/board/tree/org) renders first, with summary sections (metrics + active/live panel) below it.
|
||||
- **Controls Popup**: Import, state filter, Show system agents toggle, and global Heartbeat Speed are grouped under a compact `Controls` trigger (`aria-haspopup`, `aria-expanded`, Escape/outside-click dismissal).
|
||||
- **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
|
||||
|
||||
@@ -81,11 +81,18 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".agents-view-header")).toBe(true);
|
||||
expect(hasClass(".agents-view-title")).toBe(true);
|
||||
expect(hasClass(".agents-view-controls")).toBe(true);
|
||||
expect(hasClass(".agents-view-primary-actions")).toBe(true);
|
||||
expect(hasClass(".agents-view-content")).toBe(true);
|
||||
expect(hasClass(".agent-controls-trigger")).toBe(true);
|
||||
expect(hasClass(".agent-controls-trigger--active")).toBe(true);
|
||||
expect(hasClass(".agent-controls-panel")).toBe(true);
|
||||
expect(hasClass(".agent-controls")).toBe(true);
|
||||
expect(hasClass(".agent-controls-filters")).toBe(true);
|
||||
expect(hasClass(".agent-state-filter")).toBe(true);
|
||||
expect(hasClass(".agent-state-filter-select")).toBe(true);
|
||||
expect(hasClass(".agent-system-filter")).toBe(true);
|
||||
expect(hasClass(".agent-controls-actions")).toBe(true);
|
||||
expect(hasClass(".agent-global-controls")).toBe(true);
|
||||
expect(hasClass(".agent-board")).toBe(true);
|
||||
expect(hasClass(".agent-board-card")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26551,6 +26551,7 @@ html .column.drag-over * {
|
||||
|
||||
/* === Agent List View === */
|
||||
.agents-view {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
@@ -26584,6 +26585,51 @@ html .column.drag-over * {
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agents-view-primary-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-controls-trigger {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-controls-trigger--active {
|
||||
border-color: var(--todo);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.agent-controls-panel {
|
||||
position: absolute;
|
||||
top: calc(var(--header-height) + var(--space-sm));
|
||||
right: var(--space-lg);
|
||||
width: min(calc(var(--space-2xl) * 16), calc(100% - var(--space-2xl)));
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--card);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.agent-controls-panel .agent-controls {
|
||||
margin-bottom: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.agent-controls-panel .agent-controls-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.agent-controls-panel .agent-global-controls {
|
||||
margin-bottom: 0;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.agents-view-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
@@ -28934,6 +28980,25 @@ html .column.drag-over * {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agents-view-primary-actions {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.agents-view-primary-actions .btn,
|
||||
.agents-view-primary-actions .btn-icon {
|
||||
min-height: calc(var(--space-2xl) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-controls-panel {
|
||||
left: var(--space-md);
|
||||
right: var(--space-md);
|
||||
top: calc(var(--header-height) + var(--space-sm));
|
||||
width: auto;
|
||||
max-height: calc(100dvh - var(--header-height) - var(--space-2xl));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.agent-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -28958,6 +29023,10 @@ html .column.drag-over * {
|
||||
min-height: calc(var(--space-2xl) + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-controls-panel .agent-controls-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.agent-state-filter,
|
||||
.agent-system-filter {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user