Skip to content

Commit 1e8af71

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 1e8af71

File tree

4 files changed

+164
-21
lines changed

4 files changed

+164
-21
lines changed

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>

src/browser/features/Settings/Sections/HeartbeatSection.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,8 +154,29 @@ export function HeartbeatSection() {
154154
return;
155155
}
156156

157+
if (!heartbeatDefaultIntervalLoadedOk && !heartbeatDefaultIntervalEditedSinceLoadRef.current) {
158+
return;
159+
}
160+
157161
const parsedMinutes = parseIntervalMinutes(draftIntervalMinutes);
162+
163+
// Empty or invalid input: clear the global override so the hardcoded default applies.
158164
if (parsedMinutes == null) {
165+
setDraftIntervalMinutes("");
166+
167+
heartbeatDefaultIntervalUpdateChainRef.current =
168+
heartbeatDefaultIntervalUpdateChainRef.current
169+
.catch(() => {
170+
/* Best-effort. */
171+
})
172+
.then(() => api.config.updateHeartbeatDefaultIntervalMs({ intervalMs: null }))
173+
.then(() => {
174+
setHeartbeatDefaultIntervalLoadedOk(true);
175+
heartbeatDefaultIntervalEditedSinceLoadRef.current = false;
176+
})
177+
.catch(() => {
178+
/* Best-effort. */
179+
});
159180
return;
160181
}
161182

@@ -165,10 +186,6 @@ export function HeartbeatSection() {
165186
setDraftIntervalMinutes(clampedMinutesValue);
166187
}
167188

168-
if (!heartbeatDefaultIntervalLoadedOk && !heartbeatDefaultIntervalEditedSinceLoadRef.current) {
169-
return;
170-
}
171-
172189
heartbeatDefaultIntervalUpdateChainRef.current = heartbeatDefaultIntervalUpdateChainRef.current
173190
.catch(() => {
174191
// Best-effort only.

src/browser/hooks/useWorkspaceHeartbeat.ts

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88

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

11+
interface HeartbeatGlobalDefaults {
12+
intervalMs: number;
13+
}
14+
1115
export type HeartbeatFormSettings = WorkspaceHeartbeatSettings;
1216

1317
interface UseWorkspaceHeartbeatParams {
@@ -20,27 +24,45 @@ export interface UseWorkspaceHeartbeatResult {
2024
isSaving: boolean;
2125
error: string | null;
2226
save: (next: HeartbeatFormSettings) => Promise<boolean>;
27+
/** Global default prompt from config, for use as placeholder text. */
28+
globalDefaultPrompt: string | undefined;
2329
}
2430

25-
function getDefaultHeartbeatSettings(): HeartbeatFormSettings {
31+
function normalizeHeartbeatDefaultMessage(message?: string): string | undefined {
32+
const trimmedMessage = message?.trim();
33+
return trimmedMessage ?? undefined;
34+
}
35+
36+
function getDefaultHeartbeatSettings(
37+
globalDefaults?: HeartbeatGlobalDefaults
38+
): HeartbeatFormSettings {
39+
// Only seed the interval from global defaults. The message is intentionally left
40+
// empty so saving without editing does not persist the global prompt as a
41+
// workspace-level override (the backend handles prompt fallback at execution time).
2642
return {
2743
enabled: false,
28-
intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS,
44+
intervalMs: globalDefaults?.intervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS,
2945
contextMode: HEARTBEAT_DEFAULT_CONTEXT_MODE,
3046
};
3147
}
3248

3349
function normalizeHeartbeatSettings(
34-
heartbeat: WorkspaceHeartbeatSettings | null
50+
heartbeat: WorkspaceHeartbeatSettings | null,
51+
globalDefaults?: HeartbeatGlobalDefaults
3552
): HeartbeatFormSettings {
3653
if (!heartbeat) {
37-
return getDefaultHeartbeatSettings();
54+
return getDefaultHeartbeatSettings(globalDefaults);
3855
}
3956

40-
const trimmedMessage = heartbeat.message?.trim();
57+
const message = normalizeHeartbeatDefaultMessage(heartbeat.message);
4158
const contextMode = heartbeat.contextMode ?? HEARTBEAT_DEFAULT_CONTEXT_MODE;
42-
return trimmedMessage
43-
? { ...heartbeat, message: trimmedMessage, contextMode }
59+
return message
60+
? {
61+
enabled: heartbeat.enabled,
62+
intervalMs: heartbeat.intervalMs,
63+
contextMode,
64+
message,
65+
}
4466
: {
4567
enabled: heartbeat.enabled,
4668
intervalMs: heartbeat.intervalMs,
@@ -67,6 +89,7 @@ export function useWorkspaceHeartbeat(
6789
const [isLoading, setIsLoading] = useState(true);
6890
const [isSaving, setIsSaving] = useState(false);
6991
const [error, setError] = useState<string | null>(null);
92+
const [globalDefaultPrompt, setGlobalDefaultPrompt] = useState<string | undefined>(undefined);
7093

7194
// Guards for out-of-order async responses (e.g., rapid toggles or workspace switches).
7295
const currentWorkspaceIdRef = useRef<string | null>(workspaceId);
@@ -78,6 +101,7 @@ export function useWorkspaceHeartbeat(
78101
setIsLoading(true);
79102
setIsSaving(false);
80103
setError(null);
104+
setGlobalDefaultPrompt(undefined);
81105

82106
if (!workspaceId) {
83107
setIsLoading(false);
@@ -89,20 +113,30 @@ export function useWorkspaceHeartbeat(
89113
}
90114

91115
let cancelled = false;
92-
void api.workspace.heartbeat
93-
.get({ workspaceId })
94-
.then((result) => {
116+
void Promise.all([
117+
api.workspace.heartbeat.get({ workspaceId }),
118+
api.config.getConfig().catch(() => null),
119+
])
120+
.then(([heartbeat, config]) => {
95121
if (cancelled) return;
96122
if (currentWorkspaceIdRef.current !== workspaceId) return;
97123

98-
setSettings(normalizeHeartbeatSettings(result));
124+
const globalDefaults = config
125+
? {
126+
intervalMs: config.heartbeatDefaultIntervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS,
127+
}
128+
: undefined;
129+
130+
setSettings(normalizeHeartbeatSettings(heartbeat, globalDefaults));
131+
setGlobalDefaultPrompt(config?.heartbeatDefaultPrompt?.trim() ?? undefined);
99132
setError(null);
100133
setIsLoading(false);
101134
})
102135
.catch((loadError) => {
103136
if (cancelled) return;
104137
if (currentWorkspaceIdRef.current !== workspaceId) return;
105138

139+
setGlobalDefaultPrompt(undefined);
106140
setError(
107141
getHeartbeatErrorMessage(loadError, "Failed to load workspace heartbeat settings")
108142
);
@@ -168,5 +202,5 @@ export function useWorkspaceHeartbeat(
168202
[api, workspaceId]
169203
);
170204

171-
return { settings, isLoading, isSaving, error, save };
205+
return { settings, isLoading, isSaving, error, save, globalDefaultPrompt };
172206
}

0 commit comments

Comments
 (0)