Skip to content

Commit 215128a

Browse files
committed
Guard heartbeat prompt saves after load failures
1 parent fda70f8 commit 215128a

File tree

2 files changed

+57
-5
lines changed

2 files changed

+57
-5
lines changed

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

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ interface MockConfig {
1212
heartbeatDefaultIntervalMs?: number;
1313
}
1414

15+
interface MockOptions {
16+
getConfigError?: Error;
17+
}
18+
1519
interface MockAPIClient {
1620
config: {
1721
getConfig: () => Promise<MockConfig>;
@@ -34,7 +38,7 @@ void mock.module("@/browser/contexts/API", () => ({
3438

3539
import { HeartbeatSection } from "./HeartbeatSection";
3640

37-
function createMockAPI(configOverrides: Partial<MockConfig> = {}) {
41+
function createMockAPI(configOverrides: Partial<MockConfig> = {}, options: MockOptions = {}) {
3842
const config: MockConfig = {
3943
...configOverrides,
4044
};
@@ -55,7 +59,11 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}) {
5559
return {
5660
api: {
5761
config: {
58-
getConfig: mock(() => Promise.resolve({ ...config })),
62+
getConfig: mock(() =>
63+
options.getConfigError
64+
? Promise.reject(options.getConfigError)
65+
: Promise.resolve({ ...config })
66+
),
5967
updateHeartbeatDefaultPrompt: updateHeartbeatDefaultPromptMock,
6068
updateHeartbeatDefaultIntervalMs: updateHeartbeatDefaultIntervalMsMock,
6169
},
@@ -65,9 +73,12 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}) {
6573
};
6674
}
6775

68-
function renderHeartbeatSection(configOverrides: Partial<MockConfig> = {}) {
76+
function renderHeartbeatSection(
77+
configOverrides: Partial<MockConfig> = {},
78+
options: MockOptions = {}
79+
) {
6980
const { api, updateHeartbeatDefaultPromptMock, updateHeartbeatDefaultIntervalMsMock } =
70-
createMockAPI(configOverrides);
81+
createMockAPI(configOverrides, options);
7182
mockApi = api;
7283

7384
const view = render(
@@ -126,6 +137,42 @@ describe("HeartbeatSection", () => {
126137
});
127138
});
128139

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+
129176
test("loads and saves the default heartbeat threshold", async () => {
130177
const initialIntervalMs = 45 * 60_000;
131178
const { view, updateHeartbeatDefaultIntervalMsMock } = renderHeartbeatSection({

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export function HeartbeatSection() {
6969
const heartbeatDefaultIntervalLoadNonceRef = useRef(0);
7070
const heartbeatDefaultPromptUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
7171
const heartbeatDefaultIntervalUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
72+
const heartbeatDefaultPromptEditedSinceLoadRef = useRef(false);
7273
const heartbeatDefaultIntervalEditedSinceLoadRef = useRef(false);
7374

7475
useEffect(() => {
@@ -80,6 +81,7 @@ export function HeartbeatSection() {
8081
setHeartbeatDefaultPromptLoadedOk(false);
8182
setHeartbeatDefaultIntervalLoaded(false);
8283
setHeartbeatDefaultIntervalLoadedOk(false);
84+
heartbeatDefaultPromptEditedSinceLoadRef.current = false;
8385
heartbeatDefaultIntervalEditedSinceLoadRef.current = false;
8486

8587
const heartbeatDefaultPromptNonce = ++heartbeatDefaultPromptLoadNonceRef.current;
@@ -92,6 +94,7 @@ export function HeartbeatSection() {
9294
setHeartbeatDefaultPrompt(cfg.heartbeatDefaultPrompt ?? "");
9395
setHeartbeatDefaultPromptLoaded(true);
9496
setHeartbeatDefaultPromptLoadedOk(true);
97+
heartbeatDefaultPromptEditedSinceLoadRef.current = false;
9598
}
9699

97100
if (heartbeatDefaultIntervalNonce === heartbeatDefaultIntervalLoadNonceRef.current) {
@@ -122,7 +125,7 @@ export function HeartbeatSection() {
122125
}
123126

124127
const trimmedDefaultPrompt = heartbeatDefaultPrompt.trim();
125-
if (!heartbeatDefaultPromptLoadedOk && !trimmedDefaultPrompt) {
128+
if (!heartbeatDefaultPromptLoadedOk && !heartbeatDefaultPromptEditedSinceLoadRef.current) {
126129
return;
127130
}
128131

@@ -139,6 +142,7 @@ export function HeartbeatSection() {
139142
)
140143
.then(() => {
141144
setHeartbeatDefaultPromptLoadedOk(true);
145+
heartbeatDefaultPromptEditedSinceLoadRef.current = false;
142146
})
143147
.catch(() => {
144148
// Best-effort persistence.
@@ -231,6 +235,7 @@ export function HeartbeatSection() {
231235
value={heartbeatDefaultPrompt}
232236
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
233237
heartbeatDefaultPromptLoadNonceRef.current++;
238+
heartbeatDefaultPromptEditedSinceLoadRef.current = true;
234239
setHeartbeatDefaultPromptLoaded(true);
235240
setHeartbeatDefaultPrompt(event.target.value);
236241
}}

0 commit comments

Comments
 (0)