Skip to content

Commit b4ea427

Browse files
authored
🤖 feat: add heartbeat settings section with global defaults (#3114)
## Summary Adds global heartbeat defaults (prompt and threshold) behind a dedicated Heartbeat settings section, gated by the Workspace Heartbeats experiment. The per-workspace heartbeat modal inherits the global default interval and shows the global default prompt as placeholder text. ## Background Heartbeats currently use hardcoded defaults unless each workspace overrides them individually. Users want global defaults they can configure once, so new workspaces inherit them automatically. ## Implementation - **New HeartbeatSection** in Settings (bottom of the list, experiment-gated): - Default threshold: interval input in minutes (5 to 1440 min), saved on blur, clearable to return to hardcoded default - Default prompt: textarea for custom heartbeat instructions, saved on blur - **Global config fields**: `heartbeatDefaultPrompt` and `heartbeatDefaultIntervalMs` in `AppConfigOnDiskSchema`, persisted in `~/.mux/config.json`. - **IPC routes**: `config.updateHeartbeatDefaultPrompt` and `config.updateHeartbeatDefaultIntervalMs`. - **Per-workspace modal inheritance**: - Interval: unconfigured workspaces default to the global interval - Prompt: shown as placeholder text in the message textarea (not pre-filled as a value, so saving without editing doesn't create a workspace override) - `globalDefaultPrompt` is cleared on load start and error to prevent stale placeholders across workspace switches - **Backend precedence**: - Prompt: per-workspace message > global `heartbeatDefaultPrompt` > hardcoded fallback - Interval: per-workspace `intervalMs` > global `heartbeatDefaultIntervalMs` > hardcoded fallback ## Validation - IPC persistence tests for both config fields - HeartbeatSection UI tests (renders with experiment, loads values, saves on blur) - HeartbeatService scheduling tests (global interval fallback, workspace override wins) - Modal test (inherits global interval when no per-workspace config) > Mux working on behalf of Mike --- _Generated with `mux` • Model: `anthropic:claude-opus-4-6` • Thinking: `xhigh` • Cost: `$40.98`_ <!-- mux-attribution: model=anthropic:claude-opus-4-6 thinking=xhigh costs=40.98 -->
1 parent 8870ef9 commit b4ea427

17 files changed

Lines changed: 959 additions & 34 deletions

File tree

src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.test.tsx

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { ReactNode } from "react";
44
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
55
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
66
import { installDom } from "../../../../tests/ui/dom";
7+
import * as APIModule from "@/browser/contexts/API";
8+
import type { APIClient, UseAPIResult } from "@/browser/contexts/API";
79
import * as WorkspaceHeartbeatHookModule from "@/browser/hooks/useWorkspaceHeartbeat";
810
import type { HeartbeatFormSettings } from "@/browser/hooks/useWorkspaceHeartbeat";
911
import {
@@ -33,6 +35,27 @@ let saveResult = true;
3335
let hookError: string | null = null;
3436
let hookIsLoading = false;
3537
let hookIsSaving = false;
38+
let useWorkspaceHeartbeatSpy: ReturnType<
39+
typeof spyOn<typeof WorkspaceHeartbeatHookModule, "useWorkspaceHeartbeat">
40+
>;
41+
42+
type ConnectedUseAPIResult = Extract<UseAPIResult, { status: "connected" }>;
43+
interface WorkspaceHeartbeatTestAPI {
44+
workspace: {
45+
heartbeat: {
46+
get: (input: { workspaceId: string }) => Promise<HeartbeatFormSettings | null>;
47+
set: (
48+
_input: unknown
49+
) => Promise<{ success: true; data: void } | { success: false; error: string }>;
50+
};
51+
};
52+
config: {
53+
getConfig: () => Promise<{
54+
heartbeatDefaultIntervalMs?: number;
55+
heartbeatDefaultPrompt?: string;
56+
}>;
57+
};
58+
}
3659

3760
function createHeartbeatSettings(
3861
overrides: Partial<HeartbeatFormSettings> = {}
@@ -45,6 +68,16 @@ function createHeartbeatSettings(
4568
};
4669
}
4770

71+
function createConnectedUseAPIResult(api: WorkspaceHeartbeatTestAPI): ConnectedUseAPIResult {
72+
return {
73+
api: api as APIClient,
74+
status: "connected",
75+
error: null,
76+
authenticate: () => undefined,
77+
retry: () => undefined,
78+
};
79+
}
80+
4881
const LONG_HEARTBEAT_MESSAGE = "Review pending work and summarize next steps. ".repeat(30).trim();
4982

5083
describe("WorkspaceHeartbeatModal", () => {
@@ -57,7 +90,10 @@ describe("WorkspaceHeartbeatModal", () => {
5790
hookIsLoading = false;
5891
hookIsSaving = false;
5992

60-
spyOn(WorkspaceHeartbeatHookModule, "useWorkspaceHeartbeat").mockImplementation((params) => {
93+
useWorkspaceHeartbeatSpy = spyOn(
94+
WorkspaceHeartbeatHookModule,
95+
"useWorkspaceHeartbeat"
96+
).mockImplementation((params) => {
6197
const workspaceId = params.workspaceId;
6298
return {
6399
settings:
@@ -67,6 +103,7 @@ describe("WorkspaceHeartbeatModal", () => {
67103
isLoading: hookIsLoading,
68104
isSaving: hookIsSaving,
69105
error: hookError,
106+
globalDefaultPrompt: undefined,
70107
save: (next: HeartbeatFormSettings) => {
71108
if (!workspaceId) {
72109
return Promise.resolve(false);
@@ -136,6 +173,59 @@ describe("WorkspaceHeartbeatModal", () => {
136173
expect(onOpenChange).toHaveBeenCalledWith(false);
137174
});
138175

176+
test("loads global heartbeat defaults when the workspace has no saved heartbeat config", async () => {
177+
useWorkspaceHeartbeatSpy.mockRestore();
178+
179+
const globalIntervalMs = 6 * 60_000;
180+
const globalPrompt = "test";
181+
const workspaceHeartbeatGetMock = mock(() => Promise.resolve(null));
182+
const workspaceHeartbeatSetMock = mock(() =>
183+
Promise.resolve({ success: true as const, data: undefined })
184+
);
185+
const getConfigMock = mock(() =>
186+
Promise.resolve({
187+
heartbeatDefaultIntervalMs: globalIntervalMs,
188+
heartbeatDefaultPrompt: globalPrompt,
189+
})
190+
);
191+
const mockApi: WorkspaceHeartbeatTestAPI = {
192+
workspace: {
193+
heartbeat: {
194+
get: workspaceHeartbeatGetMock,
195+
set: workspaceHeartbeatSetMock,
196+
},
197+
},
198+
config: {
199+
getConfig: getConfigMock,
200+
},
201+
};
202+
spyOn(APIModule, "useAPI").mockImplementation(() => createConnectedUseAPIResult(mockApi));
203+
204+
const view = render(
205+
<WorkspaceHeartbeatModal
206+
workspaceId="ws-1"
207+
open={true}
208+
onOpenChange={mock((_open: boolean) => undefined)}
209+
/>
210+
);
211+
212+
const intervalField = (await waitFor(() =>
213+
view.getByLabelText("Heartbeat interval in minutes")
214+
)) as HTMLInputElement;
215+
expect(intervalField.value).toBe("6");
216+
expect(workspaceHeartbeatGetMock).toHaveBeenCalledWith({ workspaceId: "ws-1" });
217+
expect(getConfigMock).toHaveBeenCalled();
218+
219+
fireEvent.click(view.getByRole("switch", { name: "Enable workspace heartbeats" }));
220+
221+
const messageField = (await waitFor(() =>
222+
view.getByLabelText("Heartbeat message")
223+
)) as HTMLTextAreaElement;
224+
// Global prompt is not seeded into the form to avoid persisting it as a workspace
225+
// override on save. The backend handles prompt fallback at execution time.
226+
expect(messageField.value).toBe("");
227+
});
228+
139229
test("saves the selected heartbeat context mode and updates helper copy", async () => {
140230
settingsByWorkspaceId.set(
141231
"ws-1",

src/browser/components/WorkspaceHeartbeatModal/WorkspaceHeartbeatModal.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,11 @@ function getDraftMessageForSave(value: string): string {
124124
}
125125

126126
export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
127-
const { settings, isLoading, isSaving, error, save } = useWorkspaceHeartbeat({
128-
workspaceId: props.open ? props.workspaceId : null,
129-
});
127+
const { settings, isLoading, isSaving, error, save, globalDefaultPrompt } = useWorkspaceHeartbeat(
128+
{
129+
workspaceId: props.open ? props.workspaceId : null,
130+
}
131+
);
130132
const settingsContextMode = settings.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE;
131133
const [draftEnabled, setDraftEnabled] = useState(false);
132134
const [draftIntervalMinutes, setDraftIntervalMinutes] = useState(
@@ -351,7 +353,7 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
351353
}}
352354
disabled={isSaving}
353355
className="border-border-medium bg-background-secondary text-foreground focus:border-accent focus:ring-accent min-h-[120px] w-full resize-y rounded-md border p-3 text-sm leading-relaxed focus:ring-1 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
354-
placeholder={HEARTBEAT_DEFAULT_MESSAGE_BODY}
356+
placeholder={globalDefaultPrompt ?? HEARTBEAT_DEFAULT_MESSAGE_BODY}
355357
aria-label="Heartbeat message"
356358
/>
357359
</div>
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import "../../../../../tests/ui/dom";
2+
3+
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
4+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
5+
6+
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
7+
import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat";
8+
import { installDom } from "../../../../../tests/ui/dom";
9+
10+
interface MockConfig {
11+
heartbeatDefaultPrompt?: string;
12+
heartbeatDefaultIntervalMs?: number;
13+
}
14+
15+
interface MockOptions {
16+
getConfigError?: Error;
17+
}
18+
19+
interface MockAPIClient {
20+
config: {
21+
getConfig: () => Promise<MockConfig>;
22+
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
23+
updateHeartbeatDefaultIntervalMs: (input: { intervalMs?: number | null }) => Promise<void>;
24+
};
25+
}
26+
27+
let mockApi: MockAPIClient;
28+
29+
void mock.module("@/browser/contexts/API", () => ({
30+
useAPI: () => ({
31+
api: mockApi,
32+
status: "connected" as const,
33+
error: null,
34+
authenticate: () => undefined,
35+
retry: () => undefined,
36+
}),
37+
}));
38+
39+
import { HeartbeatSection } from "./HeartbeatSection";
40+
41+
function createMockAPI(configOverrides: Partial<MockConfig> = {}, options: MockOptions = {}) {
42+
const config: MockConfig = {
43+
...configOverrides,
44+
};
45+
46+
const updateHeartbeatDefaultPromptMock = mock(
47+
({ defaultPrompt }: { defaultPrompt?: string | null }) => {
48+
config.heartbeatDefaultPrompt = defaultPrompt?.trim() ? defaultPrompt.trim() : undefined;
49+
return Promise.resolve();
50+
}
51+
);
52+
const updateHeartbeatDefaultIntervalMsMock = mock(
53+
({ intervalMs }: { intervalMs?: number | null }) => {
54+
config.heartbeatDefaultIntervalMs = intervalMs ?? undefined;
55+
return Promise.resolve();
56+
}
57+
);
58+
59+
return {
60+
api: {
61+
config: {
62+
getConfig: mock(() =>
63+
options.getConfigError
64+
? Promise.reject(options.getConfigError)
65+
: Promise.resolve({ ...config })
66+
),
67+
updateHeartbeatDefaultPrompt: updateHeartbeatDefaultPromptMock,
68+
updateHeartbeatDefaultIntervalMs: updateHeartbeatDefaultIntervalMsMock,
69+
},
70+
},
71+
updateHeartbeatDefaultPromptMock,
72+
updateHeartbeatDefaultIntervalMsMock,
73+
};
74+
}
75+
76+
function renderHeartbeatSection(
77+
configOverrides: Partial<MockConfig> = {},
78+
options: MockOptions = {}
79+
) {
80+
const { api, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock } =
81+
createMockAPI(configOverrides, options);
82+
mockApi = api;
83+
84+
const view = render(
85+
<ThemeProvider forcedTheme="dark">
86+
<HeartbeatSection />
87+
</ThemeProvider>
88+
);
89+
90+
return { view, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock };
91+
}
92+
93+
describe("HeartbeatSection", () => {
94+
let cleanupDom: (() => void) | null = null;
95+
96+
beforeEach(() => {
97+
cleanupDom = installDom();
98+
});
99+
100+
afterEach(() => {
101+
cleanup();
102+
mock.restore();
103+
cleanupDom?.();
104+
cleanupDom = null;
105+
});
106+
107+
test("renders the default heartbeat controls", async () => {
108+
const { view } = renderHeartbeatSection();
109+
110+
const thresholdInput = (await waitFor(() =>
111+
view.getByLabelText("Default heartbeat threshold in minutes")
112+
)) as HTMLInputElement;
113+
114+
expect(thresholdInput.value).toBe("30");
115+
const promptField = view.getByLabelText("Default heartbeat prompt") as HTMLTextAreaElement;
116+
expect(promptField.placeholder).toBe(HEARTBEAT_DEFAULT_MESSAGE_BODY);
117+
});
118+
119+
test("loads and saves the default heartbeat prompt", async () => {
120+
const initialPrompt = "Review pending work before acting.";
121+
const { view, updateHeartbeatDefaultPromptMock } = renderHeartbeatSection({
122+
heartbeatDefaultPrompt: initialPrompt,
123+
});
124+
125+
const promptField = (await waitFor(() =>
126+
view.getByLabelText("Default heartbeat prompt")
127+
)) as HTMLTextAreaElement;
128+
129+
expect(promptField.value).toBe(initialPrompt);
130+
131+
fireEvent.blur(promptField);
132+
133+
await waitFor(() => {
134+
expect(updateHeartbeatDefaultPromptMock.mock.calls[0]?.[0]).toEqual({
135+
defaultPrompt: initialPrompt,
136+
});
137+
});
138+
});
139+
140+
test("skips saving a stale prompt after config reload fails until the user edits", async () => {
141+
const initialPrompt = "Review pending work before acting.";
142+
const { view } = renderHeartbeatSection({
143+
heartbeatDefaultPrompt: initialPrompt,
144+
});
145+
146+
const promptField = (await waitFor(() =>
147+
view.getByLabelText("Default heartbeat prompt")
148+
)) as HTMLTextAreaElement;
149+
150+
await waitFor(() => {
151+
expect(promptField.value).toBe(initialPrompt);
152+
});
153+
154+
const failedReload = createMockAPI({}, { getConfigError: new Error("load failed") });
155+
mockApi = failedReload.api;
156+
view.rerender(
157+
<ThemeProvider forcedTheme="dark">
158+
<HeartbeatSection />
159+
</ThemeProvider>
160+
);
161+
162+
await waitFor(() => {
163+
expect(promptField.value).toBe(initialPrompt);
164+
});
165+
166+
const failedReloadPromptField = view.getByLabelText(
167+
"Default heartbeat prompt"
168+
) as HTMLTextAreaElement;
169+
170+
fireEvent.blur(failedReloadPromptField);
171+
await Promise.resolve();
172+
await Promise.resolve();
173+
expect(failedReload.updateHeartbeatDefaultPromptMock).not.toHaveBeenCalled();
174+
});
175+
176+
test("loads and saves the default heartbeat threshold", async () => {
177+
const initialIntervalMs = 45 * 60_000;
178+
const { view, updateHeartbeatDefaultIntervalMsMock } = renderHeartbeatSection({
179+
heartbeatDefaultIntervalMs: initialIntervalMs,
180+
});
181+
182+
const thresholdInput = (await waitFor(() =>
183+
view.getByLabelText("Default heartbeat threshold in minutes")
184+
)) as HTMLInputElement;
185+
186+
expect(thresholdInput.value).toBe("45");
187+
188+
fireEvent.blur(thresholdInput);
189+
190+
await waitFor(() => {
191+
expect(updateHeartbeatDefaultIntervalMsMock.mock.calls[0]?.[0]).toEqual({
192+
intervalMs: initialIntervalMs,
193+
});
194+
});
195+
});
196+
});

0 commit comments

Comments
 (0)