diff --git a/src/browser/features/Settings/Sections/GeneralSection.test.tsx b/src/browser/features/Settings/Sections/GeneralSection.test.tsx index ab4b14f3d0..209b30457f 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.test.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.test.tsx @@ -1,8 +1,9 @@ import React from "react"; import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react"; -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import { ThemeProvider } from "@/browser/contexts/ThemeContext"; import * as ActualSelectPrimitiveModule from "@/browser/components/SelectPrimitive/SelectPrimitive"; +import * as ExperimentsModule from "@/browser/hooks/useExperiments"; import { installDom } from "../../../../../tests/ui/dom"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR, @@ -17,6 +18,7 @@ interface MockConfig { coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior; worktreeArchiveBehavior: WorktreeArchiveBehavior; llmDebugLogs: boolean; + heartbeatDefaultPrompt?: string; } interface MockAPIClient { @@ -27,6 +29,7 @@ interface MockAPIClient { worktreeArchiveBehavior: WorktreeArchiveBehavior; }) => Promise; updateLlmDebugLogs: (input: { enabled: boolean }) => Promise; + updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise; }; server: { getSshHost: () => Promise; @@ -213,6 +216,15 @@ function createMockAPI(configOverrides: Partial = {}): MockAPISetup return Promise.resolve(); }), + updateHeartbeatDefaultPrompt: mock( + ({ defaultPrompt }: { defaultPrompt?: string | null }) => { + config.heartbeatDefaultPrompt = defaultPrompt?.trim() + ? defaultPrompt.trim() + : undefined; + + return Promise.resolve(); + } + ), }, server: { getSshHost: mock(() => Promise.resolve(null)), @@ -233,6 +245,7 @@ describe("GeneralSection", () => { beforeEach(() => { cleanupDom = installDom(); + spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => true); }); afterEach(() => { @@ -430,6 +443,20 @@ describe("GeneralSection", () => { }); }); + test("renders the heartbeat default prompt textarea when the experiment is enabled", () => { + const { view } = renderGeneralSection(); + + expect(view.getByLabelText("Default heartbeat prompt")).toBeTruthy(); + }); + + test("hides the heartbeat default prompt textarea when the experiment is disabled", () => { + spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => false); + + const { view } = renderGeneralSection(); + + expect(view.queryByLabelText("Default heartbeat prompt")).toBeNull(); + }); + test("disables archive settings until config finishes loading", async () => { const { api, getConfigMock, updateCoderPrefsMock } = createMockAPI({ worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, diff --git a/src/browser/features/Settings/Sections/GeneralSection.tsx b/src/browser/features/Settings/Sections/GeneralSection.tsx index 15da4e1c46..38d9bfa0f4 100644 --- a/src/browser/features/Settings/Sections/GeneralSection.tsx +++ b/src/browser/features/Settings/Sections/GeneralSection.tsx @@ -10,7 +10,9 @@ import { import { Input } from "@/browser/components/Input/Input"; import { Switch } from "@/browser/components/Switch/Switch"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { useExperimentValue } from "@/browser/hooks/useExperiments"; import { useAPI } from "@/browser/contexts/API"; +import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { EDITOR_CONFIG_KEY, @@ -39,6 +41,7 @@ import { isWorktreeArchiveBehavior, type WorktreeArchiveBehavior, } from "@/common/config/worktreeArchiveBehavior"; +import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat"; // Guard against corrupted/old persisted settings (e.g. from a downgraded build). const ALLOWED_EDITOR_TYPES: ReadonlySet = new Set([ @@ -160,6 +163,7 @@ const isBrowserMode = typeof window !== "undefined" && !window.api; export function GeneralSection() { const { themePreference, setTheme } = useTheme(); const { api } = useAPI(); + const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); const [launchBehavior, setLaunchBehavior] = usePersistedState( LAUNCH_BEHAVIOR_KEY, "dashboard" @@ -203,6 +207,9 @@ export function GeneralSection() { ); const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false); const [llmDebugLogs, setLlmDebugLogs] = useState(false); + const [heartbeatDefaultPrompt, setHeartbeatDefaultPrompt] = useState(""); + const [heartbeatDefaultPromptLoaded, setHeartbeatDefaultPromptLoaded] = useState(false); + const [heartbeatDefaultPromptLoadedOk, setHeartbeatDefaultPromptLoadedOk] = useState(false); const archiveBehaviorLoadNonceRef = useRef(0); const archiveBehaviorRef = useRef(DEFAULT_CODER_ARCHIVE_BEHAVIOR); const worktreeArchiveBehaviorRef = useRef( @@ -210,11 +217,13 @@ export function GeneralSection() { ); const llmDebugLogsLoadNonceRef = useRef(0); + const heartbeatDefaultPromptLoadNonceRef = useRef(0); // updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid // selections can't race and persist a stale value via out-of-order writes. const archiveBehaviorUpdateChainRef = useRef>(Promise.resolve()); const llmDebugLogsUpdateChainRef = useRef>(Promise.resolve()); + const heartbeatDefaultPromptUpdateChainRef = useRef>(Promise.resolve()); const archiveBehaviorPendingUpdateRef = useRef( undefined ); @@ -228,8 +237,11 @@ export function GeneralSection() { } setArchiveSettingsLoaded(false); + setHeartbeatDefaultPromptLoaded(false); + setHeartbeatDefaultPromptLoadedOk(false); const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current; const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current; + const heartbeatDefaultPromptNonce = ++heartbeatDefaultPromptLoadNonceRef.current; void api.config .getConfig() @@ -256,6 +268,12 @@ export function GeneralSection() { if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) { setLlmDebugLogs(cfg.llmDebugLogs === true); } + + if (heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef.current) { + setHeartbeatDefaultPrompt(cfg.heartbeatDefaultPrompt ?? ""); + setHeartbeatDefaultPromptLoaded(true); + setHeartbeatDefaultPromptLoadedOk(true); + } }) .catch(() => { if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) { @@ -263,6 +281,12 @@ export function GeneralSection() { // config read failure and the next user change can persist a fresh value. setArchiveSettingsLoaded(true); } + + if (heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef.current) { + // Keep the field editable after load failures, but avoid clearing an existing saved + // prompt unless the user has actively typed a replacement in this session. + setHeartbeatDefaultPromptLoaded(true); + } }); }, [api]); @@ -367,6 +391,35 @@ export function GeneralSection() { }); }; + const handleHeartbeatDefaultPromptBlur = useCallback(() => { + if (!heartbeatDefaultPromptLoaded || !api?.config?.updateHeartbeatDefaultPrompt) { + return; + } + + const trimmedDefaultPrompt = heartbeatDefaultPrompt.trim(); + if (!heartbeatDefaultPromptLoadedOk && !trimmedDefaultPrompt) { + return; + } + + setHeartbeatDefaultPrompt(trimmedDefaultPrompt); + + heartbeatDefaultPromptUpdateChainRef.current = heartbeatDefaultPromptUpdateChainRef.current + .catch(() => { + // Best-effort only. + }) + .then(() => + api.config.updateHeartbeatDefaultPrompt({ + defaultPrompt: trimmedDefaultPrompt || null, + }) + ) + .then(() => { + setHeartbeatDefaultPromptLoadedOk(true); + }) + .catch(() => { + // Best-effort persistence. + }); + }, [api, heartbeatDefaultPrompt, heartbeatDefaultPromptLoaded, heartbeatDefaultPromptLoadedOk]); + // Load SSH host from server on mount (browser mode only) useEffect(() => { if (isBrowserMode && api) { @@ -559,6 +612,30 @@ export function GeneralSection() { aria-label="Toggle API Debug Logs" /> + {workspaceHeartbeatsEnabled ? ( +
+ +