Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import "../../../../tests/ui/dom";

import type { ReactNode } from "react";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import { cleanup, fireEvent, render, waitFor } from "@testing-library/react";
import { installDom } from "../../../../tests/ui/dom";
import * as WorkspaceHeartbeatHookModule from "@/browser/hooks/useWorkspaceHeartbeat";
import type { HeartbeatFormSettings } from "@/browser/hooks/useWorkspaceHeartbeat";
import {
HEARTBEAT_DEFAULT_INTERVAL_MS,
HEARTBEAT_DEFAULT_MESSAGE_BODY,
} from "@/constants/heartbeat";

void mock.module("@/browser/components/Dialog/Dialog", () => ({
Dialog: (props: { open: boolean; children: ReactNode }) =>
props.open ? <div>{props.children}</div> : null,
DialogContent: (props: { children: ReactNode; className?: string }) => (
<div className={props.className}>{props.children}</div>
),
DialogHeader: (props: { children: ReactNode }) => <div>{props.children}</div>,
DialogTitle: (props: { children: ReactNode; className?: string }) => (
<h2 className={props.className}>{props.children}</h2>
),
}));

import { WorkspaceHeartbeatModal } from "./WorkspaceHeartbeatModal";

let cleanupDom: (() => void) | null = null;
let settingsByWorkspaceId = new Map<string, HeartbeatFormSettings>();
let saveCalls: Array<{ workspaceId: string; next: HeartbeatFormSettings }> = [];
let saveResult = true;
let hookError: string | null = null;
let hookIsLoading = false;
let hookIsSaving = false;

function createHeartbeatSettings(
overrides: Partial<HeartbeatFormSettings> = {}
): HeartbeatFormSettings {
return {
enabled: false,
intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS,
...overrides,
};
}

describe("WorkspaceHeartbeatModal", () => {
beforeEach(() => {
cleanupDom = installDom();
settingsByWorkspaceId = new Map<string, HeartbeatFormSettings>();
saveCalls = [];
saveResult = true;
hookError = null;
hookIsLoading = false;
hookIsSaving = false;

spyOn(WorkspaceHeartbeatHookModule, "useWorkspaceHeartbeat").mockImplementation((params) => {
const workspaceId = params.workspaceId;
return {
settings:
workspaceId == null
? createHeartbeatSettings()
: (settingsByWorkspaceId.get(workspaceId) ?? createHeartbeatSettings()),
isLoading: hookIsLoading,
isSaving: hookIsSaving,
error: hookError,
save: (next: HeartbeatFormSettings) => {
if (!workspaceId) {
return Promise.resolve(false);
}

saveCalls.push({ workspaceId, next });
if (saveResult) {
settingsByWorkspaceId.set(workspaceId, { ...next });
}
return Promise.resolve(saveResult);
},
} satisfies WorkspaceHeartbeatHookModule.UseWorkspaceHeartbeatResult;
});
});

afterEach(() => {
cleanup();
mock.restore();
cleanupDom?.();
cleanupDom = null;
});

test("reveals the message field when enabled and saves a custom message", async () => {
settingsByWorkspaceId.set(
"ws-1",
createHeartbeatSettings({
enabled: false,
message: "Review the current workspace status before acting.",
})
);
const onOpenChange = mock((_open: boolean) => undefined);
const view = render(
<WorkspaceHeartbeatModal workspaceId="ws-1" open={true} onOpenChange={onOpenChange} />
);

expect(view.queryByLabelText("Heartbeat message")).toBeNull();

fireEvent.click(view.getByRole("switch", { name: "Enable workspace heartbeats" }));

const messageField = (await waitFor(() =>
view.getByLabelText("Heartbeat message")
)) as HTMLTextAreaElement;
expect(messageField.value).toBe("Review the current workspace status before acting.");
expect(messageField.placeholder).toBe(HEARTBEAT_DEFAULT_MESSAGE_BODY);

fireEvent.input(messageField, {
target: { value: "Check the pending review queue and summarize next steps." },
});
await waitFor(() => {
expect(messageField.value).toBe("Check the pending review queue and summarize next steps.");
});
fireEvent.click(view.getByRole("button", { name: "Save" }));

await waitFor(() => {
expect(saveCalls).toEqual([
{
workspaceId: "ws-1",
next: {
enabled: true,
intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS,
message: "Check the pending review queue and summarize next steps.",
},
},
]);
});
expect(onOpenChange).toHaveBeenCalledWith(false);
});

test("reopens with the saved message for the same workspace and does not bleed across workspaces", async () => {
settingsByWorkspaceId.set(
"ws-1",
createHeartbeatSettings({
enabled: true,
message: "Review the open PR status before sending a follow-up.",
})
);
settingsByWorkspaceId.set("ws-2", createHeartbeatSettings({ enabled: true }));

const view = render(
<WorkspaceHeartbeatModal
workspaceId="ws-1"
open={true}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);

await waitFor(() => {
expect((view.getByLabelText("Heartbeat message") as HTMLTextAreaElement).value).toBe(
"Review the open PR status before sending a follow-up."
);
});

view.rerender(
<WorkspaceHeartbeatModal
workspaceId="ws-1"
open={false}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);
view.rerender(
<WorkspaceHeartbeatModal
workspaceId="ws-1"
open={true}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);

await waitFor(() => {
expect((view.getByLabelText("Heartbeat message") as HTMLTextAreaElement).value).toBe(
"Review the open PR status before sending a follow-up."
);
});

view.rerender(
<WorkspaceHeartbeatModal
workspaceId="ws-2"
open={true}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);

await waitFor(() => {
expect((view.getByLabelText("Heartbeat message") as HTMLTextAreaElement).value).toBe("");
});
});

test("clearing the message removes the override instead of saving whitespace", async () => {
settingsByWorkspaceId.set(
"ws-1",
createHeartbeatSettings({
enabled: true,
message: "Review the open PR status before sending a follow-up.",
})
);

const view = render(
<WorkspaceHeartbeatModal
workspaceId="ws-1"
open={true}
onOpenChange={mock((_open: boolean) => undefined)}
/>
);

const messageField = (await waitFor(() =>
view.getByLabelText("Heartbeat message")
)) as HTMLTextAreaElement;
fireEvent.input(messageField, { target: { value: " " } });
await waitFor(() => {
expect(messageField.value).toBe(" ");
});
fireEvent.click(view.getByRole("button", { name: "Save" }));

await waitFor(() => {
expect(saveCalls).toEqual([
{
workspaceId: "ws-1",
next: {
enabled: true,
intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS,
message: "",
},
},
]);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import { useWorkspaceHeartbeat } from "@/browser/hooks/useWorkspaceHeartbeat";
import assert from "@/common/utils/assert";
import {
HEARTBEAT_DEFAULT_INTERVAL_MS,
HEARTBEAT_DEFAULT_MESSAGE_BODY,
HEARTBEAT_MAX_INTERVAL_MS,
HEARTBEAT_MAX_MESSAGE_LENGTH,
HEARTBEAT_MIN_INTERVAL_MS,
} from "@/constants/heartbeat";

Expand Down Expand Up @@ -81,6 +83,15 @@ function getValidationErrorMessage(value: string): string | null {
return null;
}

function normalizeDraftMessage(value: string): string | undefined {
const trimmedValue = value.trim();
return trimmedValue.length > 0 ? trimmedValue : undefined;
}

function getDraftMessageForSave(value: string): string {
return normalizeDraftMessage(value) ?? "";
}

export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const { settings, isLoading, isSaving, error, save } = useWorkspaceHeartbeat({
workspaceId: props.open ? props.workspaceId : null,
Expand All @@ -89,12 +100,15 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const [draftIntervalMinutes, setDraftIntervalMinutes] = useState(
formatIntervalMinutes(HEARTBEAT_DEFAULT_INTERVAL_MS)
);
const [draftMessage, setDraftMessage] = useState("");
const [draftDirty, setDraftDirty] = useState(false);
const previousOpenRef = useRef(props.open);
const previousWorkspaceIdRef = useRef(props.workspaceId);
const lastSyncedSettingsRef = useRef<Pick<typeof settings, "enabled" | "intervalMs"> | null>(
null
);
const messageTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const lastSyncedSettingsRef = useRef<Pick<
typeof settings,
"enabled" | "intervalMs" | "message"
> | null>(null);

useEffect(() => {
const didOpen = props.open && !previousOpenRef.current;
Expand All @@ -103,7 +117,8 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const settingsChanged =
lastSyncedSettings == null ||
lastSyncedSettings.enabled !== settings.enabled ||
lastSyncedSettings.intervalMs !== settings.intervalMs;
lastSyncedSettings.intervalMs !== settings.intervalMs ||
lastSyncedSettings.message !== settings.message;

previousOpenRef.current = props.open;
previousWorkspaceIdRef.current = props.workspaceId;
Expand All @@ -116,13 +131,23 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
if (didOpen || workspaceChanged || (!draftDirty && settingsChanged)) {
setDraftEnabled(settings.enabled);
setDraftIntervalMinutes(formatIntervalMinutes(settings.intervalMs));
setDraftMessage(settings.message ?? "");
setDraftDirty(false);
lastSyncedSettingsRef.current = {
enabled: settings.enabled,
intervalMs: settings.intervalMs,
message: settings.message,
};
}
}, [draftDirty, isLoading, props.open, props.workspaceId, settings.enabled, settings.intervalMs]);
}, [
draftDirty,
isLoading,
props.open,
props.workspaceId,
settings.enabled,
settings.intervalMs,
settings.message,
]);

const validationError = getValidationErrorMessage(draftIntervalMinutes);
const errorMessages = [validationError, error].filter(
Expand Down Expand Up @@ -156,6 +181,9 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
const didSave = await save({
enabled: draftEnabled,
intervalMs: parsedMinutes * MS_PER_MINUTE,
// Read directly from the textarea on save so the final keystroke is preserved even if the
// click lands before React finishes flushing the last state update.
message: getDraftMessageForSave(messageTextareaRef.current?.value ?? draftMessage),
});
if (didSave) {
props.onOpenChange(false);
Expand Down Expand Up @@ -230,6 +258,35 @@ export function WorkspaceHeartbeatModal(props: WorkspaceHeartbeatModalProps) {
<span className="text-muted text-sm">min</span>
</div>
</div>

{draftEnabled && (
<div className="mt-4 space-y-2">
<label htmlFor="workspace-heartbeat-message" className="block">
<div className="text-foreground text-sm font-medium">Message</div>
<div className="text-muted mt-1 text-xs">
Leave empty to use the default heartbeat message.
</div>
</label>
<textarea
ref={messageTextareaRef}
id="workspace-heartbeat-message"
rows={4}
maxLength={HEARTBEAT_MAX_MESSAGE_LENGTH}
value={draftMessage}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
setDraftMessage(event.target.value);
setDraftDirty(true);
}}
disabled={isSaving}
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"
placeholder={HEARTBEAT_DEFAULT_MESSAGE_BODY}
aria-label="Heartbeat message"
/>
<div className="text-muted text-xs">
Max {HEARTBEAT_MAX_MESSAGE_LENGTH} characters.
</div>
</div>
)}
</div>

{errorMessages.length > 0 && (
Expand Down
10 changes: 8 additions & 2 deletions src/browser/hooks/useWorkspaceHeartbeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,13 @@ function normalizeHeartbeatSettings(
return getDefaultHeartbeatSettings();
}

return { ...heartbeat };
const trimmedMessage = heartbeat.message?.trim();
return trimmedMessage
? { ...heartbeat, message: trimmedMessage }
: {
enabled: heartbeat.enabled,
intervalMs: heartbeat.intervalMs,
};
}

function getHeartbeatErrorMessage(error: unknown, fallbackMessage: string): string {
Expand Down Expand Up @@ -134,7 +140,7 @@ export function useWorkspaceHeartbeat(
return true;
}

setSettings({ ...next });
setSettings(normalizeHeartbeatSettings(next));
setIsSaving(false);
return true;
} catch (saveError) {
Expand Down
Loading
Loading