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:
Fusion
2026-04-23 00:57:10 -07:00
committed by gsxdsm
parent 5621334d02
commit 954fa1cd62
8 changed files with 203 additions and 78 deletions

View File

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

View File

@@ -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 () => {

View File

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