diff --git a/.changeset/fn-8552-heartbeat-disabled-option.md b/.changeset/fn-8552-heartbeat-disabled-option.md
new file mode 100644
index 0000000000..44a355166c
--- /dev/null
+++ b/.changeset/fn-8552-heartbeat-disabled-option.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Let agent-card heartbeat controls disable and re-enable scheduling.
+category: feature
+dev: The interval dropdown now preserves runtime configuration while updating runtimeConfig.enabled.
diff --git a/README.md b/README.md
index 68ccc30103..2e60ba2e17 100644
--- a/README.md
+++ b/README.md
@@ -192,7 +192,7 @@ Here is the same fleet re-skinned into the **Ember** theme (dark graphite with a
-Import a team and every agent shows up here — role, reports-to chain, heartbeat, and token share. The **Agents** roster in Ember:
+Import a team and every agent shows up here — role, reports-to chain, heartbeat, and token share. Each agent card's heartbeat dropdown shows **Disabled** when scheduling is persisted off; choose Disabled to pause heartbeats while retaining its cadence, or select an interval to re-enable them. The **Agents** roster in Ember:
diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx
index 16c9ecd327..18bed18ce7 100644
--- a/packages/dashboard/app/components/AgentsView.tsx
+++ b/packages/dashboard/app/components/AgentsView.tsx
@@ -60,6 +60,7 @@ function getAgentRoles(t: TFunction<"app">): { value: AgentCapability; label: st
}
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
+const HEARTBEAT_DISABLED_OPTION_VALUE = "__disabled__";
const ORG_CHART_SCALE_MIN = 0.25;
const ORG_CHART_SCALE_MAX = 3;
@@ -935,6 +936,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{
runtimeConfig: {
...(agent.runtimeConfig ?? {}),
+ enabled: true,
heartbeatIntervalMs: newIntervalMs,
},
},
@@ -949,6 +951,28 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
}
};
+ const handleHeartbeatDisabled = async (agent: Agent) => {
+ setUpdatingHeartbeatAgentId(agent.id);
+ try {
+ await updateAgent(
+ agent.id,
+ {
+ runtimeConfig: {
+ ...(agent.runtimeConfig ?? {}),
+ enabled: false,
+ },
+ },
+ projectId,
+ );
+ addToast(t("agents.heartbeatDisabled", "Heartbeat disabled for {{name}}", { name: agent.name }), "success");
+ void loadAgents();
+ } catch (err) {
+ addToast(t("agents.heartbeatIntervalUpdateFailed", "Failed to update heartbeat interval: {{error}}", { error: getErrorMessage(err) }), "error");
+ } finally {
+ setUpdatingHeartbeatAgentId(null);
+ }
+ };
+
/**
* Handle saving custom heartbeat interval from typed minutes input.
* Validation behavior:
@@ -989,6 +1013,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{
runtimeConfig: {
...(agent.runtimeConfig ?? {}),
+ enabled: true,
heartbeatIntervalMs: MIN_HEARTBEAT_INTERVAL_MS,
},
},
@@ -1019,6 +1044,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
{
runtimeConfig: {
...(agent.runtimeConfig ?? {}),
+ enabled: true,
heartbeatIntervalMs: intervalMs,
},
},
@@ -1855,6 +1881,14 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const stateCardClass = getStateCardClass("agent-card", agent.state);
const configuredIntervalMs = resolveHeartbeatIntervalMs(agent.runtimeConfig?.heartbeatIntervalMs);
const heartbeatOptions = getHeartbeatIntervalOptions(configuredIntervalMs);
+ /*
+ * FNXC:AgentHeartbeatControls 2026-07-23-12:43:
+ * The list-card select is the scheduling control: an explicit false wins over its saved cadence,
+ * while absent enabled remains backwards-compatible as enabled. Interval changes always persist
+ * enabled: true; disabling retains the complete runtime configuration and saved cadence.
+ */
+ const isHeartbeatDisabled = agent.runtimeConfig?.enabled === false;
+ const heartbeatSelectValue = isHeartbeatDisabled ? HEARTBEAT_DISABLED_OPTION_VALUE : String(configuredIntervalMs);
const isUpdatingHeartbeat = updatingHeartbeatAgentId === agent.id;
const modelLabel = getAgentModelLabel(agent);
return (
@@ -2056,10 +2090,12 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
<>
{
const value = e.target.value;
- if (value === "__custom__") {
+ if (value === HEARTBEAT_DISABLED_OPTION_VALUE) {
+ void handleHeartbeatDisabled(agent);
+ } else if (value === "__custom__") {
handleSelectCustomHeartbeat(agent);
} else {
void handleHeartbeatIntervalChange(agent, Number(value));
@@ -2068,6 +2104,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
disabled={isUpdatingHeartbeat}
aria-label={t("agents.setHeartbeatAria", "Set heartbeat interval for {{name}}", { name: agent.name })}
>
+ {t("agents.heartbeatDisabledOption", "Disabled")}
{heartbeatOptions.map((option) => (
{option.label}
diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
index 7fedde9290..12af9ba4c3 100644
--- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx
@@ -1128,6 +1128,121 @@ describe("AgentsView", () => {
expect(intervalSelect.options[intervalSelect.selectedIndex]?.text).toBe("1h");
});
+ it("maps persisted heartbeat enablement to the dropdown across desktop and mobile", async () => {
+ const heartbeatAgents: Agent[] = [
+ { ...mockAgents[1], id: "agent-disabled", name: "Disabled Agent", runtimeConfig: { enabled: false, heartbeatIntervalMs: 900_000 } },
+ { ...mockAgents[1], id: "agent-enabled", name: "Enabled Agent", runtimeConfig: { enabled: true, heartbeatIntervalMs: 900_000 } },
+ { ...mockAgents[1], id: "agent-legacy", name: "Legacy Agent", runtimeConfig: { heartbeatIntervalMs: 900_000 } },
+ { ...mockAgents[1], id: "agent-default", name: "Default Agent", runtimeConfig: undefined },
+ ];
+ mockFetchAgents.mockResolvedValue(heartbeatAgents);
+ mockFetchAgentStats.mockResolvedValue({ total: heartbeatAgents.length, byState: {}, byRole: {} });
+ mockViewportMode.mockReturnValue("mobile");
+
+ renderView( );
+
+ await waitFor(() => {
+ expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("__disabled__");
+ });
+ for (const name of ["Enabled Agent", "Legacy Agent"]) {
+ const select = screen.getByLabelText(`Set heartbeat interval for ${name}`) as HTMLSelectElement;
+ expect(select.value).toBe("900000");
+ expect(select.options[select.selectedIndex]?.text).toBe("15m");
+ }
+ expect((screen.getByLabelText("Set heartbeat interval for Default Agent") as HTMLSelectElement).value).toBe("3600000");
+ });
+
+ it("preserves runtime config while disabling and re-enabling heartbeat intervals", async () => {
+ const disabledAgent: Agent = {
+ ...mockAgents[1],
+ id: "agent-disabled",
+ name: "Disabled Agent",
+ runtimeConfig: {
+ enabled: false,
+ heartbeatIntervalMs: 900_000,
+ heartbeatTimeoutMs: 120_000,
+ maxConcurrentRuns: 3,
+ messageResponseMode: "on-heartbeat",
+ },
+ };
+ mockFetchAgents
+ .mockResolvedValueOnce([disabledAgent])
+ .mockResolvedValueOnce([disabledAgent])
+ .mockResolvedValueOnce([{ ...disabledAgent, runtimeConfig: { ...disabledAgent.runtimeConfig, enabled: true, heartbeatIntervalMs: 1_800_000 } }])
+ .mockResolvedValueOnce([{ ...disabledAgent, runtimeConfig: { ...disabledAgent.runtimeConfig, enabled: false, heartbeatIntervalMs: 1_800_000 } }]);
+ mockFetchAgentStats.mockResolvedValue({ total: 1, byState: {}, byRole: {} });
+
+ renderView( );
+
+ const select = await screen.findByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement;
+ expect(select.value).toBe("__disabled__");
+ expect(Array.from(select.options).map((option) => option.text)).toContain("Disabled");
+
+ fireEvent.change(select, { target: { value: "__disabled__" } });
+ await waitFor(() => {
+ expect(mockUpdateAgent).toHaveBeenLastCalledWith(
+ "agent-disabled",
+ {
+ runtimeConfig: {
+ enabled: false,
+ heartbeatIntervalMs: 900_000,
+ heartbeatTimeoutMs: 120_000,
+ maxConcurrentRuns: 3,
+ messageResponseMode: "on-heartbeat",
+ },
+ },
+ undefined,
+ );
+ });
+
+ fireEvent.change(select, { target: { value: "1800000" } });
+ await waitFor(() => {
+ expect(mockUpdateAgent).toHaveBeenLastCalledWith(
+ "agent-disabled",
+ {
+ runtimeConfig: {
+ enabled: true,
+ heartbeatIntervalMs: 1_800_000,
+ heartbeatTimeoutMs: 120_000,
+ maxConcurrentRuns: 3,
+ messageResponseMode: "on-heartbeat",
+ },
+ },
+ undefined,
+ );
+ });
+
+ await waitFor(() => {
+ expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("1800000");
+ });
+
+ const refreshedSelect = screen.getByLabelText("Set heartbeat interval for Disabled Agent");
+ fireEvent.change(refreshedSelect, { target: { value: "__disabled__" } });
+ await waitFor(() => {
+ expect((screen.getByLabelText("Set heartbeat interval for Disabled Agent") as HTMLSelectElement).value).toBe("__disabled__");
+ });
+
+ fireEvent.change(screen.getByLabelText("Set heartbeat interval for Disabled Agent"), { target: { value: "__custom__" } });
+ const customInput = await screen.findByLabelText("Custom heartbeat interval in minutes for Disabled Agent");
+ fireEvent.change(customInput, { target: { value: "7" } });
+ fireEvent.click(screen.getByRole("button", { name: "Save" }));
+ await waitFor(() => {
+ expect(mockUpdateAgent).toHaveBeenLastCalledWith(
+ "agent-disabled",
+ {
+ runtimeConfig: {
+ enabled: true,
+ heartbeatIntervalMs: 420_000,
+ heartbeatTimeoutMs: 120_000,
+ maxConcurrentRuns: 3,
+ messageResponseMode: "on-heartbeat",
+ },
+ },
+ undefined,
+ );
+ });
+ });
+
it("updates agent heartbeat interval from preset dropdown", async () => {
renderView( );