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:
|
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
|
- Detail/config panels
|
||||||
- Runtime metrics
|
- Runtime metrics and active-agent live cards rendered below the main collection
|
||||||
- Run history
|
- Run history
|
||||||
- Task assignment context
|
- Task assignment context
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ The `runtimeConfig` field on agents supports the following options:
|
|||||||
| `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings |
|
| `budgetConfig` | `AgentBudgetConfig` | — | Token budget governance settings |
|
||||||
|
|
||||||
Heartbeat values are validated and minimum-clamped to 5 minutes (300,000 ms).
|
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)
|
## Agent Instructions (Dashboard)
|
||||||
|
|
||||||
|
|||||||
@@ -179,7 +179,9 @@ In addition to the global mobile foundation, several power-user surfaces now inc
|
|||||||
|
|
||||||
- **AgentsView**
|
- **AgentsView**
|
||||||
- Board mode collapses to a single column (`.agent-board { grid-template-columns: 1fr; }`).
|
- 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.
|
- 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.
|
- 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.
|
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**:
|
**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`)
|
- **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.
|
- **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
|
- **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-header")).toBe(true);
|
||||||
expect(hasClass(".agents-view-title")).toBe(true);
|
expect(hasClass(".agents-view-title")).toBe(true);
|
||||||
expect(hasClass(".agents-view-controls")).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(".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")).toBe(true);
|
||||||
expect(hasClass(".agent-controls-filters")).toBe(true);
|
expect(hasClass(".agent-controls-filters")).toBe(true);
|
||||||
expect(hasClass(".agent-state-filter")).toBe(true);
|
expect(hasClass(".agent-state-filter")).toBe(true);
|
||||||
expect(hasClass(".agent-state-filter-select")).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")).toBe(true);
|
||||||
expect(hasClass(".agent-board-card")).toBe(true);
|
expect(hasClass(".agent-board-card")).toBe(true);
|
||||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, useMemo } from "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 } from "lucide-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 type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||||
import { AgentDetailView } from "./AgentDetailView";
|
import { AgentDetailView } from "./AgentDetailView";
|
||||||
@@ -265,6 +265,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
});
|
});
|
||||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||||
|
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||||
|
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||||
|
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const controlsPanelId = useId();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = getScopedItem("fn-agent-view", projectId);
|
const saved = getScopedItem("fn-agent-view", projectId);
|
||||||
@@ -403,6 +407,34 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
};
|
};
|
||||||
}, [loadAgents]);
|
}, [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) => {
|
const handleStateChange = async (agentId: string, newState: AgentState) => {
|
||||||
if (transitioningAgentIds.has(agentId)) return;
|
if (transitioningAgentIds.has(agentId)) return;
|
||||||
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
|
setTransitioningAgentIds(prev => new Set(prev).add(agentId));
|
||||||
@@ -675,116 +707,145 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
<Network size={16} />
|
<Network size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="agents-view-primary-actions">
|
||||||
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">
|
|
||||||
<button
|
<button
|
||||||
className="btn"
|
ref={controlsTriggerRef}
|
||||||
onClick={() => setIsImporting(true)}
|
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} />
|
<SlidersHorizontal size={16} />
|
||||||
Import
|
Controls
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn--primary"
|
className="btn btn--primary"
|
||||||
onClick={() => setIsCreating(true)}
|
onClick={() => {
|
||||||
|
setIsCreating(true);
|
||||||
|
setIsControlsPanelOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus size={16} />
|
<Plus size={16} />
|
||||||
New Agent
|
New Agent
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-icon"
|
||||||
|
onClick={() => void loadAgents()}
|
||||||
|
title="Refresh"
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} className={isLoading ? "spin" : ""} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Global Heartbeat Speed Control */}
|
{isControlsPanelOpen && (
|
||||||
<div className="agent-global-controls">
|
<div
|
||||||
<div className="heartbeat-multiplier-group">
|
ref={controlsPanelRef}
|
||||||
<div className="heartbeat-multiplier-controls">
|
id={controlsPanelId}
|
||||||
<label htmlFor="globalHeartbeatMultiplier" className="heartbeat-multiplier-label">
|
className="agent-controls-panel"
|
||||||
Heartbeat Speed
|
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>
|
</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>
|
</div>
|
||||||
<small className="text-secondary">
|
|
||||||
Scales all agent heartbeat intervals. ×0.5 = twice as fast, ×2.0 = twice as slow. Default: ×1.0
|
<div className="agent-controls-actions">
|
||||||
</small>
|
<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>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="agents-view-content">
|
||||||
|
|
||||||
<NewAgentDialog
|
<NewAgentDialog
|
||||||
isOpen={isCreating}
|
isOpen={isCreating}
|
||||||
@@ -800,13 +861,7 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Metrics Bar */}
|
{/* Agent Collection */}
|
||||||
<AgentMetricsBar stats={stats} />
|
|
||||||
|
|
||||||
{/* Active Agents Panel - Live streaming cards */}
|
|
||||||
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
|
||||||
|
|
||||||
{/* Agent List */}
|
|
||||||
{agentView === "tree" ? (
|
{agentView === "tree" ? (
|
||||||
<div className="agent-tree__view">
|
<div className="agent-tree__view">
|
||||||
{displayAgents.length === 0 ? (
|
{displayAgents.length === 0 ? (
|
||||||
@@ -1205,6 +1260,10 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Secondary sections */}
|
||||||
|
<AgentMetricsBar stats={stats} />
|
||||||
|
<ActiveAgentsPanel agents={activeAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent Detail Modal */}
|
{/* Agent Detail Modal */}
|
||||||
|
|||||||
@@ -103,6 +103,15 @@ describe("AgentsView", () => {
|
|||||||
mockUpdateSettings.mockResolvedValue({});
|
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", () => {
|
describe("rendering", () => {
|
||||||
it("renders the agents view header", async () => {
|
it("renders the agents view header", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
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 () => {
|
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
@@ -173,13 +234,10 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("shows terminated agents when explicitly filtered", async () => {
|
it("shows terminated agents when explicitly filtered", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch to terminated filter
|
// Switch to terminated filter
|
||||||
const filterSelect = screen.getByDisplayValue("All States");
|
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -740,10 +798,7 @@ describe("AgentsView", () => {
|
|||||||
describe("filter agents by state", () => {
|
describe("filter agents by state", () => {
|
||||||
it("renders the state filter with styled container", async () => {
|
it("renders the state filter with styled container", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Styled filter container exists
|
// Styled filter container exists
|
||||||
const filterContainer = document.querySelector(".agent-state-filter");
|
const filterContainer = document.querySelector(".agent-state-filter");
|
||||||
@@ -756,12 +811,9 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("can filter agents by state", async () => {
|
it("can filter agents by state", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
await waitFor(() => {
|
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const filterSelect = screen.getByDisplayValue("All States");
|
|
||||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -771,12 +823,9 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("clears filter when selecting 'all'", async () => {
|
it("clears filter when selecting 'all'", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
await waitFor(() => {
|
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const filterSelect = screen.getByDisplayValue("All States");
|
|
||||||
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
fireEvent.change(filterSelect, { target: { value: "idle" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -794,10 +843,7 @@ describe("AgentsView", () => {
|
|||||||
describe("show system agents toggle", () => {
|
describe("show system agents toggle", () => {
|
||||||
it("renders the system agents checkbox", async () => {
|
it("renders the system agents checkbox", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Show system agents")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkbox should be unchecked by default
|
// Checkbox should be unchecked by default
|
||||||
const checkbox = screen.getByLabelText("Show system agents") as HTMLInputElement;
|
const checkbox = screen.getByLabelText("Show system agents") as HTMLInputElement;
|
||||||
@@ -807,10 +853,6 @@ describe("AgentsView", () => {
|
|||||||
it("passes includeEphemeral: false by default to fetchAgents", async () => {
|
it("passes includeEphemeral: false by default to fetchAgents", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default call should include includeEphemeral: false
|
// Default call should include includeEphemeral: false
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: false }, undefined);
|
expect(mockFetchAgents).toHaveBeenLastCalledWith({ includeEphemeral: false }, undefined);
|
||||||
@@ -819,10 +861,7 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("toggles system agents visibility when checkbox is clicked", async () => {
|
it("toggles system agents visibility when checkbox is clicked", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText("Show system agents");
|
const checkbox = screen.getByLabelText("Show system agents");
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
@@ -834,10 +873,7 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("combines system agents toggle with state filter", async () => {
|
it("combines system agents toggle with state filter", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// First enable system agents toggle
|
// First enable system agents toggle
|
||||||
const checkbox = screen.getByLabelText("Show system agents");
|
const checkbox = screen.getByLabelText("Show system agents");
|
||||||
@@ -848,7 +884,7 @@ describe("AgentsView", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Then filter by state
|
// Then filter by state
|
||||||
const filterSelect = screen.getByDisplayValue("All States");
|
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||||
fireEvent.change(filterSelect, { target: { value: "active" } });
|
fireEvent.change(filterSelect, { target: { value: "active" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -881,6 +917,7 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
|
expect(screen.queryByText("executor-FN-TEST")).toBeNull();
|
||||||
|
|
||||||
|
await openControlsPanel();
|
||||||
const checkbox = screen.getByLabelText("Show system agents");
|
const checkbox = screen.getByLabelText("Show system agents");
|
||||||
fireEvent.click(checkbox);
|
fireEvent.click(checkbox);
|
||||||
|
|
||||||
@@ -1184,13 +1221,10 @@ describe("AgentsView", () => {
|
|||||||
|
|
||||||
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
it("shows Delete button for terminated agents when explicitly filtered", async () => {
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch to terminated filter
|
// Switch to terminated filter
|
||||||
const filterSelect = screen.getByDisplayValue("All States");
|
const filterSelect = screen.getByLabelText("Filter agents by state");
|
||||||
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -1223,13 +1257,10 @@ describe("AgentsView", () => {
|
|||||||
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(false);
|
||||||
|
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch to terminated filter to see terminated agent
|
// 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" } });
|
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -1257,13 +1288,10 @@ describe("AgentsView", () => {
|
|||||||
vi.spyOn(window, "confirm").mockReturnValue(true);
|
vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("All States")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Switch to terminated filter to see terminated agent
|
// 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" } });
|
fireEvent.change(filterSelect, { target: { value: "terminated" } });
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -1493,10 +1521,7 @@ describe("AgentsView", () => {
|
|||||||
it("renders the global heartbeat speed control", async () => {
|
it("renders the global heartbeat speed control", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check the slider and preset are rendered
|
// Check the slider and preset are rendered
|
||||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||||
@@ -1509,20 +1534,16 @@ describe("AgentsView", () => {
|
|||||||
it("loads heartbeat multiplier from settings", async () => {
|
it("loads heartbeat multiplier from settings", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 2.5 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 2.5 });
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
|
|
||||||
await waitFor(() => {
|
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" }) as HTMLInputElement;
|
||||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" }) as HTMLInputElement;
|
expect(slider.value).toBe("2.5");
|
||||||
expect(slider.value).toBe("2.5");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("saves heartbeat multiplier when slider changes", async () => {
|
it("saves heartbeat multiplier when slider changes", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change the slider
|
// Change the slider
|
||||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
||||||
@@ -1537,10 +1558,7 @@ describe("AgentsView", () => {
|
|||||||
it("saves heartbeat multiplier when preset is selected", async () => {
|
it("saves heartbeat multiplier when preset is selected", async () => {
|
||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change the preset
|
// Change the preset
|
||||||
const preset = screen.getByLabelText("Heartbeat speed preset") as HTMLSelectElement;
|
const preset = screen.getByLabelText("Heartbeat speed preset") as HTMLSelectElement;
|
||||||
@@ -1555,10 +1573,7 @@ describe("AgentsView", () => {
|
|||||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||||
mockUpdateSettings.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
mockUpdateSettings.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||||
render(<AgentsView addToast={mockAddToast} />);
|
render(<AgentsView addToast={mockAddToast} />);
|
||||||
|
await openControlsPanel();
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Change the slider - this should start the save
|
// Change the slider - this should start the save
|
||||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
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()} />);
|
const { container } = render(<AgentsView addToast={vi.fn()} />);
|
||||||
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
await waitFor(() => expect(screen.getByText("Agents")).toBeTruthy());
|
||||||
|
|
||||||
expect(container.querySelector(".agent-controls")).toBeTruthy();
|
const controlsTrigger = screen.getByRole("button", { name: "Controls" });
|
||||||
expect(container.querySelector(".agent-controls-filters")).toBeTruthy();
|
expect(controlsTrigger.getAttribute("aria-expanded")).toBe("false");
|
||||||
expect(container.querySelector(".agent-state-filter")).toBeTruthy();
|
|
||||||
expect(container.querySelector(".agent-controls-actions")).toBeTruthy();
|
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 () => {
|
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 () => {
|
it("renders state filter select with expected options", async () => {
|
||||||
render(<AgentsView addToast={vi.fn()} />);
|
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());
|
await waitFor(() => expect(screen.getByLabelText("Filter agents by state")).toBeTruthy());
|
||||||
|
|
||||||
const select = screen.getByLabelText("Filter agents by state") as HTMLSelectElement;
|
const select = screen.getByLabelText("Filter agents by state") as HTMLSelectElement;
|
||||||
|
|||||||
@@ -26551,6 +26551,7 @@ html .column.drag-over * {
|
|||||||
|
|
||||||
/* === Agent List View === */
|
/* === Agent List View === */
|
||||||
.agents-view {
|
.agents-view {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -26584,6 +26585,51 @@ html .column.drag-over * {
|
|||||||
gap: var(--space-sm);
|
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 {
|
.agents-view-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -28934,6 +28980,25 @@ html .column.drag-over * {
|
|||||||
margin-left: auto;
|
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 {
|
.agent-controls {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
@@ -28958,6 +29023,10 @@ html .column.drag-over * {
|
|||||||
min-height: calc(var(--space-2xl) + var(--space-xs));
|
min-height: calc(var(--space-2xl) + var(--space-xs));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.agent-controls-panel .agent-controls-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
.agent-state-filter,
|
.agent-state-filter,
|
||||||
.agent-system-filter {
|
.agent-system-filter {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
Reference in New Issue
Block a user