Skip to content

Commit fda70f8

Browse files
committed
Add heartbeat settings section defaults
1 parent 8b882e6 commit fda70f8

14 files changed

Lines changed: 567 additions & 111 deletions

File tree

src/browser/features/Settings/Sections/GeneralSection.test.tsx

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import React from "react";
22
import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react";
3-
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
3+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
44
import { ThemeProvider } from "@/browser/contexts/ThemeContext";
55
import * as ActualSelectPrimitiveModule from "@/browser/components/SelectPrimitive/SelectPrimitive";
6-
import * as ExperimentsModule from "@/browser/hooks/useExperiments";
76
import { installDom } from "../../../../../tests/ui/dom";
87
import {
98
DEFAULT_CODER_ARCHIVE_BEHAVIOR,
@@ -18,7 +17,6 @@ interface MockConfig {
1817
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
1918
worktreeArchiveBehavior: WorktreeArchiveBehavior;
2019
llmDebugLogs: boolean;
21-
heartbeatDefaultPrompt?: string;
2220
}
2321

2422
interface MockAPIClient {
@@ -29,7 +27,6 @@ interface MockAPIClient {
2927
worktreeArchiveBehavior: WorktreeArchiveBehavior;
3028
}) => Promise<void>;
3129
updateLlmDebugLogs: (input: { enabled: boolean }) => Promise<void>;
32-
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
3330
};
3431
server: {
3532
getSshHost: () => Promise<string | null>;
@@ -216,15 +213,6 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup
216213

217214
return Promise.resolve();
218215
}),
219-
updateHeartbeatDefaultPrompt: mock(
220-
({ defaultPrompt }: { defaultPrompt?: string | null }) => {
221-
config.heartbeatDefaultPrompt = defaultPrompt?.trim()
222-
? defaultPrompt.trim()
223-
: undefined;
224-
225-
return Promise.resolve();
226-
}
227-
),
228216
},
229217
server: {
230218
getSshHost: mock(() => Promise.resolve(null)),
@@ -245,7 +233,6 @@ describe("GeneralSection", () => {
245233

246234
beforeEach(() => {
247235
cleanupDom = installDom();
248-
spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => true);
249236
});
250237

251238
afterEach(() => {
@@ -443,20 +430,6 @@ describe("GeneralSection", () => {
443430
});
444431
});
445432

446-
test("renders the heartbeat default prompt textarea when the experiment is enabled", () => {
447-
const { view } = renderGeneralSection();
448-
449-
expect(view.getByLabelText("Default heartbeat prompt")).toBeTruthy();
450-
});
451-
452-
test("hides the heartbeat default prompt textarea when the experiment is disabled", () => {
453-
spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => false);
454-
455-
const { view } = renderGeneralSection();
456-
457-
expect(view.queryByLabelText("Default heartbeat prompt")).toBeNull();
458-
});
459-
460433
test("disables archive settings until config finishes loading", async () => {
461434
const { api, getConfigMock, updateCoderPrefsMock } = createMockAPI({
462435
worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR,

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

Lines changed: 0 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ import {
1010
import { Input } from "@/browser/components/Input/Input";
1111
import { Switch } from "@/browser/components/Switch/Switch";
1212
import { usePersistedState } from "@/browser/hooks/usePersistedState";
13-
import { useExperimentValue } from "@/browser/hooks/useExperiments";
1413
import { useAPI } from "@/browser/contexts/API";
15-
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
1614
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
1715
import {
1816
EDITOR_CONFIG_KEY,
@@ -41,7 +39,6 @@ import {
4139
isWorktreeArchiveBehavior,
4240
type WorktreeArchiveBehavior,
4341
} from "@/common/config/worktreeArchiveBehavior";
44-
import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat";
4542

4643
// Guard against corrupted/old persisted settings (e.g. from a downgraded build).
4744
const ALLOWED_EDITOR_TYPES: ReadonlySet<EditorType> = new Set([
@@ -163,7 +160,6 @@ const isBrowserMode = typeof window !== "undefined" && !window.api;
163160
export function GeneralSection() {
164161
const { themePreference, setTheme } = useTheme();
165162
const { api } = useAPI();
166-
const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS);
167163
const [launchBehavior, setLaunchBehavior] = usePersistedState<LaunchBehavior>(
168164
LAUNCH_BEHAVIOR_KEY,
169165
"dashboard"
@@ -207,23 +203,18 @@ export function GeneralSection() {
207203
);
208204
const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false);
209205
const [llmDebugLogs, setLlmDebugLogs] = useState(false);
210-
const [heartbeatDefaultPrompt, setHeartbeatDefaultPrompt] = useState("");
211-
const [heartbeatDefaultPromptLoaded, setHeartbeatDefaultPromptLoaded] = useState(false);
212-
const [heartbeatDefaultPromptLoadedOk, setHeartbeatDefaultPromptLoadedOk] = useState(false);
213206
const archiveBehaviorLoadNonceRef = useRef(0);
214207
const archiveBehaviorRef = useRef<CoderWorkspaceArchiveBehavior>(DEFAULT_CODER_ARCHIVE_BEHAVIOR);
215208
const worktreeArchiveBehaviorRef = useRef<WorktreeArchiveBehavior>(
216209
DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
217210
);
218211

219212
const llmDebugLogsLoadNonceRef = useRef(0);
220-
const heartbeatDefaultPromptLoadNonceRef = useRef(0);
221213

222214
// updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid
223215
// selections can't race and persist a stale value via out-of-order writes.
224216
const archiveBehaviorUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
225217
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
226-
const heartbeatDefaultPromptUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
227218
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
228219
undefined
229220
);
@@ -237,11 +228,8 @@ export function GeneralSection() {
237228
}
238229

239230
setArchiveSettingsLoaded(false);
240-
setHeartbeatDefaultPromptLoaded(false);
241-
setHeartbeatDefaultPromptLoadedOk(false);
242231
const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current;
243232
const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current;
244-
const heartbeatDefaultPromptNonce = ++heartbeatDefaultPromptLoadNonceRef.current;
245233

246234
void api.config
247235
.getConfig()
@@ -268,25 +256,13 @@ export function GeneralSection() {
268256
if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) {
269257
setLlmDebugLogs(cfg.llmDebugLogs === true);
270258
}
271-
272-
if (heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef.current) {
273-
setHeartbeatDefaultPrompt(cfg.heartbeatDefaultPrompt ?? "");
274-
setHeartbeatDefaultPromptLoaded(true);
275-
setHeartbeatDefaultPromptLoadedOk(true);
276-
}
277259
})
278260
.catch(() => {
279261
if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) {
280262
// Fall back to the safe defaults already in state so the controls can recover after a
281263
// config read failure and the next user change can persist a fresh value.
282264
setArchiveSettingsLoaded(true);
283265
}
284-
285-
if (heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef.current) {
286-
// Keep the field editable after load failures, but avoid clearing an existing saved
287-
// prompt unless the user has actively typed a replacement in this session.
288-
setHeartbeatDefaultPromptLoaded(true);
289-
}
290266
});
291267
}, [api]);
292268

@@ -391,35 +367,6 @@ export function GeneralSection() {
391367
});
392368
};
393369

394-
const handleHeartbeatDefaultPromptBlur = useCallback(() => {
395-
if (!heartbeatDefaultPromptLoaded || !api?.config?.updateHeartbeatDefaultPrompt) {
396-
return;
397-
}
398-
399-
const trimmedDefaultPrompt = heartbeatDefaultPrompt.trim();
400-
if (!heartbeatDefaultPromptLoadedOk && !trimmedDefaultPrompt) {
401-
return;
402-
}
403-
404-
setHeartbeatDefaultPrompt(trimmedDefaultPrompt);
405-
406-
heartbeatDefaultPromptUpdateChainRef.current = heartbeatDefaultPromptUpdateChainRef.current
407-
.catch(() => {
408-
// Best-effort only.
409-
})
410-
.then(() =>
411-
api.config.updateHeartbeatDefaultPrompt({
412-
defaultPrompt: trimmedDefaultPrompt || null,
413-
})
414-
)
415-
.then(() => {
416-
setHeartbeatDefaultPromptLoadedOk(true);
417-
})
418-
.catch(() => {
419-
// Best-effort persistence.
420-
});
421-
}, [api, heartbeatDefaultPrompt, heartbeatDefaultPromptLoaded, heartbeatDefaultPromptLoadedOk]);
422-
423370
// Load SSH host from server on mount (browser mode only)
424371
useEffect(() => {
425372
if (isBrowserMode && api) {
@@ -612,30 +559,6 @@ export function GeneralSection() {
612559
aria-label="Toggle API Debug Logs"
613560
/>
614561
</div>
615-
{workspaceHeartbeatsEnabled ? (
616-
<div className="py-3">
617-
<label htmlFor="heartbeat-default-prompt" className="block">
618-
<div className="text-foreground text-sm">Default heartbeat prompt</div>
619-
<div className="text-muted mt-0.5 text-xs">
620-
Used for workspace heartbeats when a workspace does not set its own message.
621-
</div>
622-
</label>
623-
<textarea
624-
id="heartbeat-default-prompt"
625-
rows={4}
626-
value={heartbeatDefaultPrompt}
627-
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
628-
heartbeatDefaultPromptLoadNonceRef.current++;
629-
setHeartbeatDefaultPromptLoaded(true);
630-
setHeartbeatDefaultPrompt(event.target.value);
631-
}}
632-
onBlur={handleHeartbeatDefaultPromptBlur}
633-
className="border-border-medium bg-background-secondary text-foreground focus:border-accent focus:ring-accent mt-3 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"
634-
placeholder={HEARTBEAT_DEFAULT_MESSAGE_BODY}
635-
aria-label="Default heartbeat prompt"
636-
/>
637-
</div>
638-
) : null}
639562
</div>
640563
</div>
641564

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 MockAPIClient {
16+
config: {
17+
getConfig: () => Promise<MockConfig>;
18+
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
19+
updateHeartbeatDefaultIntervalMs: (input: { intervalMs?: number | null }) => Promise<void>;
20+
};
21+
}
22+
23+
let mockApi: MockAPIClient;
24+
25+
void mock.module("@/browser/contexts/API", () => ({
26+
useAPI: () => ({
27+
api: mockApi,
28+
status: "connected" as const,
29+
error: null,
30+
authenticate: () => undefined,
31+
retry: () => undefined,
32+
}),
33+
}));
34+
35+
import { HeartbeatSection } from "./HeartbeatSection";
36+
37+
function createMockAPI(configOverrides: Partial<MockConfig> = {}) {
38+
const config: MockConfig = {
39+
...configOverrides,
40+
};
41+
42+
const updateHeartbeatDefaultPromptMock = mock(
43+
({ defaultPrompt }: { defaultPrompt?: string | null }) => {
44+
config.heartbeatDefaultPrompt = defaultPrompt?.trim() ? defaultPrompt.trim() : undefined;
45+
return Promise.resolve();
46+
}
47+
);
48+
const updateHeartbeatDefaultIntervalMsMock = mock(
49+
({ intervalMs }: { intervalMs?: number | null }) => {
50+
config.heartbeatDefaultIntervalMs = intervalMs ?? undefined;
51+
return Promise.resolve();
52+
}
53+
);
54+
55+
return {
56+
api: {
57+
config: {
58+
getConfig: mock(() => Promise.resolve({ ...config })),
59+
updateHeartbeatDefaultPrompt: updateHeartbeatDefaultPromptMock,
60+
updateHeartbeatDefaultIntervalMs: updateHeartbeatDefaultIntervalMsMock,
61+
},
62+
},
63+
updateHeartbeatDefaultPromptMock,
64+
updateHeartbeatDefaultIntervalMsMock,
65+
};
66+
}
67+
68+
function renderHeartbeatSection(configOverrides: Partial<MockConfig> = {}) {
69+
const { api, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock } =
70+
createMockAPI(configOverrides);
71+
mockApi = api;
72+
73+
const view = render(
74+
<ThemeProvider forcedTheme="dark">
75+
<HeartbeatSection />
76+
</ThemeProvider>
77+
);
78+
79+
return { view, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock };
80+
}
81+
82+
describe("HeartbeatSection", () => {
83+
let cleanupDom: (() => void) | null = null;
84+
85+
beforeEach(() => {
86+
cleanupDom = installDom();
87+
});
88+
89+
afterEach(() => {
90+
cleanup();
91+
mock.restore();
92+
cleanupDom?.();
93+
cleanupDom = null;
94+
});
95+
96+
test("renders the default heartbeat controls", async () => {
97+
const { view } = renderHeartbeatSection();
98+
99+
const thresholdInput = (await waitFor(() =>
100+
view.getByLabelText("Default heartbeat threshold in minutes")
101+
)) as HTMLInputElement;
102+
103+
expect(thresholdInput.value).toBe("30");
104+
const promptField = view.getByLabelText("Default heartbeat prompt") as HTMLTextAreaElement;
105+
expect(promptField.placeholder).toBe(HEARTBEAT_DEFAULT_MESSAGE_BODY);
106+
});
107+
108+
test("loads and saves the default heartbeat prompt", async () => {
109+
const initialPrompt = "Review pending work before acting.";
110+
const { view, updateHeartbeatDefaultPromptMock } = renderHeartbeatSection({
111+
heartbeatDefaultPrompt: initialPrompt,
112+
});
113+
114+
const promptField = (await waitFor(() =>
115+
view.getByLabelText("Default heartbeat prompt")
116+
)) as HTMLTextAreaElement;
117+
118+
expect(promptField.value).toBe(initialPrompt);
119+
120+
fireEvent.blur(promptField);
121+
122+
await waitFor(() => {
123+
expect(updateHeartbeatDefaultPromptMock.mock.calls[0]?.[0]).toEqual({
124+
defaultPrompt: initialPrompt,
125+
});
126+
});
127+
});
128+
129+
test("loads and saves the default heartbeat threshold", async () => {
130+
const initialIntervalMs = 45 * 60_000;
131+
const { view, updateHeartbeatDefaultIntervalMsMock } = renderHeartbeatSection({
132+
heartbeatDefaultIntervalMs: initialIntervalMs,
133+
});
134+
135+
const thresholdInput = (await waitFor(() =>
136+
view.getByLabelText("Default heartbeat threshold in minutes")
137+
)) as HTMLInputElement;
138+
139+
expect(thresholdInput.value).toBe("45");
140+
141+
fireEvent.blur(thresholdInput);
142+
143+
await waitFor(() => {
144+
expect(updateHeartbeatDefaultIntervalMsMock.mock.calls[0]?.[0]).toEqual({
145+
intervalMs: initialIntervalMs,
146+
});
147+
});
148+
});
149+
});

0 commit comments

Comments
 (0)