Skip to content

Commit 5883cae

Browse files
committed
🤖 fix heartbeat modal config defaults
Use global heartbeat defaults in the workspace modal when no workspace override exists.
1 parent 215128a commit 5883cae

File tree

2 files changed

+140
-16
lines changed

2 files changed

+140
-16
lines changed

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

Lines changed: 88 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:
@@ -136,6 +172,57 @@ describe("WorkspaceHeartbeatModal", () => {
136172
expect(onOpenChange).toHaveBeenCalledWith(false);
137173
});
138174

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

src/browser/hooks/useWorkspaceHeartbeat.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ import {
88

99
type WorkspaceHeartbeatSettings = NonNullable<FrontendWorkspaceMetadata["heartbeat"]>;
1010

11+
interface HeartbeatGlobalDefaults {
12+
intervalMs: number;
13+
message?: string;
14+
}
15+
1116
export type HeartbeatFormSettings = WorkspaceHeartbeatSettings;
1217

1318
interface UseWorkspaceHeartbeatParams {
@@ -22,25 +27,48 @@ export interface UseWorkspaceHeartbeatResult {
2227
save: (next: HeartbeatFormSettings) => Promise<boolean>;
2328
}
2429

25-
function getDefaultHeartbeatSettings(): HeartbeatFormSettings {
26-
return {
27-
enabled: false,
28-
intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS,
29-
contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
30-
};
30+
function normalizeHeartbeatDefaultMessage(message?: string): string | undefined {
31+
const trimmedMessage = message?.trim();
32+
return trimmedMessage ?? undefined;
33+
}
34+
35+
function getDefaultHeartbeatSettings(
36+
globalDefaults?: HeartbeatGlobalDefaults
37+
): HeartbeatFormSettings {
38+
const defaultMessage = normalizeHeartbeatDefaultMessage(globalDefaults?.message);
39+
return defaultMessage
40+
? {
41+
enabled: false,
42+
intervalMs: globalDefaults?.intervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS,
43+
contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
44+
message: defaultMessage,
45+
}
46+
: {
47+
enabled: false,
48+
intervalMs: globalDefaults?.intervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS,
49+
contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
50+
};
3151
}
3252

3353
function normalizeHeartbeatSettings(
34-
heartbeat: WorkspaceHeartbeatSettings | null
54+
heartbeat: WorkspaceHeartbeatSettings | null,
55+
globalDefaults?: HeartbeatGlobalDefaults
3556
): HeartbeatFormSettings {
3657
if (!heartbeat) {
37-
return getDefaultHeartbeatSettings();
58+
return getDefaultHeartbeatSettings(globalDefaults);
3859
}
3960

40-
const trimmedMessage = heartbeat.message?.trim();
61+
const message =
62+
normalizeHeartbeatDefaultMessage(heartbeat.message) ??
63+
normalizeHeartbeatDefaultMessage(globalDefaults?.message);
4164
const contextMode = heartbeat.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE;
42-
return trimmedMessage
43-
? { ...heartbeat, message: trimmedMessage, contextMode }
65+
return message
66+
? {
67+
enabled: heartbeat.enabled,
68+
intervalMs: heartbeat.intervalMs,
69+
contextMode,
70+
message,
71+
}
4472
: {
4573
enabled: heartbeat.enabled,
4674
intervalMs: heartbeat.intervalMs,
@@ -89,13 +117,22 @@ export function useWorkspaceHeartbeat(
89117
}
90118

91119
let cancelled = false;
92-
void api.workspace.heartbeat
93-
.get({ workspaceId })
94-
.then((result) => {
120+
void Promise.all([
121+
api.workspace.heartbeat.get({ workspaceId }),
122+
api.config.getConfig().catch(() => null),
123+
])
124+
.then(([heartbeat, config]) => {
95125
if (cancelled) return;
96126
if (currentWorkspaceIdRef.current !== workspaceId) return;
97127

98-
setSettings(normalizeHeartbeatSettings(result));
128+
const globalDefaults = config
129+
? {
130+
intervalMs: config.heartbeatDefaultIntervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS,
131+
message: config.heartbeatDefaultPrompt,
132+
}
133+
: undefined;
134+
135+
setSettings(normalizeHeartbeatSettings(heartbeat, globalDefaults));
99136
setError(null);
100137
setIsLoading(false);
101138
})

0 commit comments

Comments
 (0)