feat(FN-2222): move heartbeat multiplier control to Agents screen
- Add heartbeat multiplier global control UI to AgentsView with supporting behavior and tests - Remove heartbeat multiplier field from Settings modal scheduling section and update related test coverage - Add mobile-specific CSS adjustments for agent global controls and extend mobile view assertions - Update docs to reflect the new heartbeat multiplier location in agents and settings references
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 type { Agent, AgentCapability, AgentState, OrgTreeNode } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree } from "../api";
|
||||
import { fetchAgents, updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
import { AgentDetailView } from "./AgentDetailView";
|
||||
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
|
||||
import { AgentMetricsBar } from "./AgentMetricsBar";
|
||||
@@ -39,6 +39,8 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
{ value: "custom", label: "Custom", icon: "✦" },
|
||||
];
|
||||
|
||||
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
|
||||
|
||||
|
||||
function getStateBadgeClass(state: AgentState): string {
|
||||
switch (state) {
|
||||
@@ -285,6 +287,36 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [customHeartbeatAgentId, setCustomHeartbeatAgentId] = useState<string | null>(null);
|
||||
/** Custom minutes input value for each agent */
|
||||
const [customHeartbeatMinutes, setCustomHeartbeatMinutes] = useState<Record<string, string>>({});
|
||||
/** Global heartbeat multiplier loaded from project settings */
|
||||
const [heartbeatMultiplier, setHeartbeatMultiplier] = useState<number>(1);
|
||||
/** Whether the heartbeat multiplier is currently being saved */
|
||||
const [isSavingMultiplier, setIsSavingMultiplier] = useState(false);
|
||||
|
||||
// Load heartbeat multiplier from project settings on mount
|
||||
useEffect(() => {
|
||||
fetchSettings(projectId)
|
||||
.then((settings) => {
|
||||
setHeartbeatMultiplier(settings.heartbeatMultiplier ?? 1);
|
||||
})
|
||||
.catch(() => {
|
||||
// Use default on error
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
/** Handle saving heartbeat multiplier to project settings */
|
||||
const handleHeartbeatMultiplierChange = useCallback(async (multiplier: number) => {
|
||||
const clampedValue = Number.isFinite(multiplier) && multiplier > 0 ? multiplier : 1;
|
||||
setHeartbeatMultiplier(clampedValue);
|
||||
setIsSavingMultiplier(true);
|
||||
try {
|
||||
await updateSettings({ heartbeatMultiplier: clampedValue }, projectId);
|
||||
addToast(`Heartbeat speed set to ×${clampedValue.toFixed(1)}`, "success");
|
||||
} catch (err: any) {
|
||||
addToast(`Failed to save heartbeat multiplier: ${err.message}`, "error");
|
||||
} finally {
|
||||
setIsSavingMultiplier(false);
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
|
||||
const hierarchy = useAgentHierarchy(agents, projectId);
|
||||
|
||||
@@ -723,6 +755,55 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
</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
|
||||
</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>
|
||||
|
||||
<NewAgentDialog
|
||||
isOpen={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
|
||||
@@ -78,7 +78,6 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const AUTO_ARCHIVE_DEFAULT_AFTER_DAYS = 2;
|
||||
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
|
||||
|
||||
/** Well-known experimental feature flags with display labels.
|
||||
* These always appear in the Experimental Features settings tab,
|
||||
@@ -1790,46 +1789,6 @@ export function SettingsModal({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group heartbeat-multiplier-group">
|
||||
<label htmlFor="heartbeatMultiplier">Heartbeat Multiplier</label>
|
||||
<div className="heartbeat-multiplier-controls">
|
||||
<input
|
||||
id="heartbeatMultiplier"
|
||||
className="heartbeat-multiplier-slider touch-target"
|
||||
type="range"
|
||||
min={0.1}
|
||||
max={10}
|
||||
step={0.1}
|
||||
value={form.heartbeatMultiplier ?? 1}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setForm((f) => ({ ...f, heartbeatMultiplier: Number.isFinite(val) && val > 0 ? val : 1 }));
|
||||
}}
|
||||
/>
|
||||
<span className="heartbeat-multiplier-value">×{(form.heartbeatMultiplier ?? 1).toFixed(1)}</span>
|
||||
</div>
|
||||
<select
|
||||
id="heartbeatMultiplierPreset"
|
||||
className="heartbeat-multiplier-preset"
|
||||
value={String(
|
||||
HEARTBEAT_MULTIPLIER_PRESETS.reduce((closest, candidate) => {
|
||||
const current = form.heartbeatMultiplier ?? 1;
|
||||
return Math.abs(candidate - current) < Math.abs(closest - current) ? candidate : closest;
|
||||
}, HEARTBEAT_MULTIPLIER_PRESETS[0])
|
||||
)}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
setForm((f) => ({ ...f, heartbeatMultiplier: Number.isFinite(val) && val > 0 ? val : 1 }));
|
||||
}}
|
||||
>
|
||||
{HEARTBEAT_MULTIPLIER_PRESETS.map((multiplier) => (
|
||||
<option key={multiplier} value={String(multiplier)}>
|
||||
×{multiplier}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<small>Scales all agent heartbeat intervals. 0.5 = twice as fast, 2.0 = twice as slow. Default: 1.0</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="taskStuckTimeoutMs">Stuck Task Timeout (minutes)</label>
|
||||
<input
|
||||
|
||||
@@ -15,6 +15,8 @@ vi.mock("../../api", () => ({
|
||||
deleteAgent: vi.fn(),
|
||||
startAgentRun: vi.fn(),
|
||||
fetchOrgTree: vi.fn(),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ heartbeatMultiplier: 1 }),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [] }),
|
||||
fetchDiscoveredSkills: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
@@ -31,6 +33,8 @@ const mockDeleteAgent = vi.mocked(apiModule.deleteAgent);
|
||||
const mockStartAgentRun = vi.mocked(apiModule.startAgentRun);
|
||||
const mockFetchOrgTree = vi.mocked((apiModule as any).fetchOrgTree);
|
||||
const mockFetchAgentStats = vi.mocked((apiModule as any).fetchAgentStats);
|
||||
const mockFetchSettings = vi.mocked((apiModule as any).fetchSettings);
|
||||
const mockUpdateSettings = vi.mocked((apiModule as any).updateSettings);
|
||||
|
||||
describe("AgentsView", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
@@ -95,6 +99,8 @@ describe("AgentsView", () => {
|
||||
status: "active",
|
||||
});
|
||||
mockFetchOrgTree.mockResolvedValue([]);
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
mockUpdateSettings.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
@@ -1443,4 +1449,86 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("global heartbeat multiplier", () => {
|
||||
it("renders the global heartbeat speed control", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Check the slider and preset are rendered
|
||||
expect(screen.getByRole("slider", { name: "Heartbeat Speed" })).toBeTruthy();
|
||||
expect(screen.getByLabelText("Heartbeat speed preset")).toBeTruthy();
|
||||
|
||||
// Check helper text
|
||||
expect(screen.getByText(/Scales all agent heartbeat intervals/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("loads heartbeat multiplier from settings", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 2.5 });
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
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();
|
||||
});
|
||||
|
||||
// Change the slider
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
||||
fireEvent.change(slider, { target: { value: "3" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ heartbeatMultiplier: 3 }, undefined);
|
||||
expect(mockAddToast).toHaveBeenCalledWith("Heartbeat speed set to ×3.0", "success");
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
// Change the preset
|
||||
const preset = screen.getByLabelText("Heartbeat speed preset") as HTMLSelectElement;
|
||||
fireEvent.change(preset, { target: { value: "0.5" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSettings).toHaveBeenCalledWith({ heartbeatMultiplier: 0.5 }, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("disables control while saving", async () => {
|
||||
mockFetchSettings.mockResolvedValue({ heartbeatMultiplier: 1 });
|
||||
mockUpdateSettings.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)));
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Heartbeat Speed")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Change the slider - this should start the save
|
||||
const slider = screen.getByRole("slider", { name: "Heartbeat Speed" });
|
||||
fireEvent.change(slider, { target: { value: "2" } });
|
||||
|
||||
// Both controls should be disabled while saving
|
||||
await waitFor(() => {
|
||||
expect(slider).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -896,45 +896,15 @@ describe("SettingsModal", () => {
|
||||
expect(checkbox.getAttribute("type")).toBe("checkbox");
|
||||
});
|
||||
|
||||
it("renders heartbeat multiplier slider in Scheduling", async () => {
|
||||
it("does not render heartbeat multiplier control in Scheduling section", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const slider = screen.getByLabelText("Heartbeat Multiplier") as HTMLInputElement;
|
||||
expect(slider.type).toBe("range");
|
||||
expect(slider.value).toBe("1");
|
||||
expect(screen.getByText("×1.0")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("updates heartbeat multiplier form state when slider changes", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const slider = screen.getByLabelText("Heartbeat Multiplier") as HTMLInputElement;
|
||||
fireEvent.change(slider, { target: { value: "2.5" } });
|
||||
|
||||
expect(slider.value).toBe("2.5");
|
||||
expect(screen.getByText("×2.5")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes heartbeatMultiplier in save payload", async () => {
|
||||
render(<SettingsModal onClose={onClose} addToast={addToast} />);
|
||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||
|
||||
fireEvent.click(screen.getByText("Scheduling"));
|
||||
|
||||
const slider = screen.getByLabelText("Heartbeat Multiplier") as HTMLInputElement;
|
||||
fireEvent.change(slider, { target: { value: "3" } });
|
||||
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
|
||||
|
||||
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
expect(payload.heartbeatMultiplier).toBe(3);
|
||||
// Heartbeat multiplier is now configured from the Agents screen, not Settings
|
||||
expect(screen.queryByLabelText("Heartbeat Multiplier")).toBeNull();
|
||||
expect(screen.queryByText(/Heartbeat Multiplier/)).toBeNull();
|
||||
});
|
||||
|
||||
it("save button calls updateSettings with form data", async () => {
|
||||
|
||||
@@ -45,6 +45,8 @@ vi.mock("../../api", () => ({
|
||||
startAgentRun: vi.fn(),
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [] })),
|
||||
fetchOrgTree: vi.fn(),
|
||||
fetchSettings: vi.fn(() => Promise.resolve({ heartbeatMultiplier: 1 })),
|
||||
updateSettings: vi.fn(() => Promise.resolve({})),
|
||||
}));
|
||||
|
||||
import {
|
||||
|
||||
Reference in New Issue
Block a user