Skip to content

Commit 8b882e6

Browse files
committed
🤖 feat: add global heartbeat default prompt
1 parent 2662aed commit 8b882e6

File tree

11 files changed

+286
-12
lines changed

11 files changed

+286
-12
lines changed

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

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import React from "react";
22
import { cleanup, fireEvent, render, waitFor, within } from "@testing-library/react";
3-
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3+
import { afterEach, beforeEach, describe, expect, mock, spyOn, 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";
67
import { installDom } from "../../../../../tests/ui/dom";
78
import {
89
DEFAULT_CODER_ARCHIVE_BEHAVIOR,
@@ -17,6 +18,7 @@ interface MockConfig {
1718
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
1819
worktreeArchiveBehavior: WorktreeArchiveBehavior;
1920
llmDebugLogs: boolean;
21+
heartbeatDefaultPrompt?: string;
2022
}
2123

2224
interface MockAPIClient {
@@ -27,6 +29,7 @@ interface MockAPIClient {
2729
worktreeArchiveBehavior: WorktreeArchiveBehavior;
2830
}) => Promise<void>;
2931
updateLlmDebugLogs: (input: { enabled: boolean }) => Promise<void>;
32+
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
3033
};
3134
server: {
3235
getSshHost: () => Promise<string | null>;
@@ -213,6 +216,15 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): MockAPISetup
213216

214217
return Promise.resolve();
215218
}),
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+
),
216228
},
217229
server: {
218230
getSshHost: mock(() => Promise.resolve(null)),
@@ -233,6 +245,7 @@ describe("GeneralSection", () => {
233245

234246
beforeEach(() => {
235247
cleanupDom = installDom();
248+
spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => true);
236249
});
237250

238251
afterEach(() => {
@@ -430,6 +443,20 @@ describe("GeneralSection", () => {
430443
});
431444
});
432445

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+
433460
test("disables archive settings until config finishes loading", async () => {
434461
const { api, getConfigMock, updateCoderPrefsMock } = createMockAPI({
435462
worktreeArchiveBehavior: DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR,

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ 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";
1314
import { useAPI } from "@/browser/contexts/API";
15+
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
1416
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
1517
import {
1618
EDITOR_CONFIG_KEY,
@@ -39,6 +41,7 @@ import {
3941
isWorktreeArchiveBehavior,
4042
type WorktreeArchiveBehavior,
4143
} from "@/common/config/worktreeArchiveBehavior";
44+
import { HEARTBEAT_DEFAULT_MESSAGE_BODY } from "@/constants/heartbeat";
4245

4346
// Guard against corrupted/old persisted settings (e.g. from a downgraded build).
4447
const ALLOWED_EDITOR_TYPES: ReadonlySet<EditorType> = new Set([
@@ -160,6 +163,7 @@ const isBrowserMode = typeof window !== "undefined" && !window.api;
160163
export function GeneralSection() {
161164
const { themePreference, setTheme } = useTheme();
162165
const { api } = useAPI();
166+
const workspaceHeartbeatsEnabled = useExperimentValue(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS);
163167
const [launchBehavior, setLaunchBehavior] = usePersistedState<LaunchBehavior>(
164168
LAUNCH_BEHAVIOR_KEY,
165169
"dashboard"
@@ -203,18 +207,23 @@ export function GeneralSection() {
203207
);
204208
const [archiveSettingsLoaded, setArchiveSettingsLoaded] = useState(false);
205209
const [llmDebugLogs, setLlmDebugLogs] = useState(false);
210+
const [heartbeatDefaultPrompt, setHeartbeatDefaultPrompt] = useState("");
211+
const [heartbeatDefaultPromptLoaded, setHeartbeatDefaultPromptLoaded] = useState(false);
212+
const [heartbeatDefaultPromptLoadedOk, setHeartbeatDefaultPromptLoadedOk] = useState(false);
206213
const archiveBehaviorLoadNonceRef = useRef(0);
207214
const archiveBehaviorRef = useRef<CoderWorkspaceArchiveBehavior>(DEFAULT_CODER_ARCHIVE_BEHAVIOR);
208215
const worktreeArchiveBehaviorRef = useRef<WorktreeArchiveBehavior>(
209216
DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
210217
);
211218

212219
const llmDebugLogsLoadNonceRef = useRef(0);
220+
const heartbeatDefaultPromptLoadNonceRef = useRef(0);
213221

214222
// updateCoderPrefs writes config.json on the backend. Serialize (and coalesce) updates so rapid
215223
// selections can't race and persist a stale value via out-of-order writes.
216224
const archiveBehaviorUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
217225
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
226+
const heartbeatDefaultPromptUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
218227
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
219228
undefined
220229
);
@@ -228,8 +237,11 @@ export function GeneralSection() {
228237
}
229238

230239
setArchiveSettingsLoaded(false);
240+
setHeartbeatDefaultPromptLoaded(false);
241+
setHeartbeatDefaultPromptLoadedOk(false);
231242
const archiveBehaviorNonce = ++archiveBehaviorLoadNonceRef.current;
232243
const llmDebugLogsNonce = ++llmDebugLogsLoadNonceRef.current;
244+
const heartbeatDefaultPromptNonce = ++heartbeatDefaultPromptLoadNonceRef.current;
233245

234246
void api.config
235247
.getConfig()
@@ -256,13 +268,25 @@ export function GeneralSection() {
256268
if (llmDebugLogsNonce === llmDebugLogsLoadNonceRef.current) {
257269
setLlmDebugLogs(cfg.llmDebugLogs === true);
258270
}
271+
272+
if (heartbeatDefaultPromptNonce === heartbeatDefaultPromptLoadNonceRef.current) {
273+
setHeartbeatDefaultPrompt(cfg.heartbeatDefaultPrompt ?? "");
274+
setHeartbeatDefaultPromptLoaded(true);
275+
setHeartbeatDefaultPromptLoadedOk(true);
276+
}
259277
})
260278
.catch(() => {
261279
if (archiveBehaviorNonce === archiveBehaviorLoadNonceRef.current) {
262280
// Fall back to the safe defaults already in state so the controls can recover after a
263281
// config read failure and the next user change can persist a fresh value.
264282
setArchiveSettingsLoaded(true);
265283
}
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+
}
266290
});
267291
}, [api]);
268292

@@ -367,6 +391,35 @@ export function GeneralSection() {
367391
});
368392
};
369393

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+
370423
// Load SSH host from server on mount (browser mode only)
371424
useEffect(() => {
372425
if (isBrowserMode && api) {
@@ -559,6 +612,30 @@ export function GeneralSection() {
559612
aria-label="Toggle API Debug Logs"
560613
/>
561614
</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}
562639
</div>
563640
</div>
564641

src/browser/stories/mocks/orpc.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ export interface MockORPCClientOptions {
132132
defaultRuntime?: RuntimeEnablementId | null;
133133
/** Initial 1Password account name for config.getConfig */
134134
onePasswordAccountName?: string | null;
135+
/** Initial global heartbeat default prompt for config.getConfig */
136+
heartbeatDefaultPrompt?: string;
135137
/** Initial route priority for config.getConfig */
136138
routePriority?: string[];
137139
/** Initial per-model route overrides for config.getConfig */
@@ -348,6 +350,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
348350
runtimeEnablement: initialRuntimeEnablement,
349351
defaultRuntime: initialDefaultRuntime,
350352
onePasswordAccountName: initialOnePasswordAccountName = null,
353+
heartbeatDefaultPrompt: initialHeartbeatDefaultPrompt,
351354
routePriority: initialRoutePriority = ["direct"],
352355
routeOverrides: initialRouteOverrides = {},
353356
agentDefinitions: initialAgentDefinitions,
@@ -495,6 +498,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
495498

496499
let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null;
497500
let onePasswordAccountName: string | null = initialOnePasswordAccountName;
501+
let heartbeatDefaultPrompt = initialHeartbeatDefaultPrompt;
498502
let routePriority = [...initialRoutePriority];
499503
let routeOverrides = { ...initialRouteOverrides };
500504
const configChangeSubscribers = new Set<(value: void) => void>();
@@ -685,7 +689,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
685689
subagentAiDefaults,
686690
muxGovernorUrl,
687691
onePasswordAccountName,
692+
heartbeatDefaultPrompt,
688693
muxGovernorEnrolled,
694+
llmDebugLogs: false,
689695
}),
690696
saveConfig: (input: {
691697
taskSettings: unknown;
@@ -769,6 +775,13 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
769775
notifyConfigChanged();
770776
return Promise.resolve(undefined);
771777
},
778+
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => {
779+
heartbeatDefaultPrompt = input.defaultPrompt?.trim()
780+
? input.defaultPrompt.trim()
781+
: undefined;
782+
notifyConfigChanged();
783+
return Promise.resolve(undefined);
784+
},
772785
updateRuntimeEnablement: (input: {
773786
projectPath?: string | null;
774787
runtimeEnablement?: Record<string, boolean> | null;

src/common/config/schemas/appConfigOnDisk.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export const AppConfigOnDiskSchema = z
4949
taskSettings: TaskSettingsSchema.optional(),
5050
muxGatewayEnabled: z.boolean().optional(),
5151
llmDebugLogs: z.boolean().optional(),
52+
heartbeatDefaultPrompt: z.string().optional(),
5253
muxGatewayModels: z.array(z.string()).optional(),
5354
routePriority: z.array(z.string()).optional(),
5455
routeOverrides: z.record(z.string(), z.string()).optional(),

src/common/orpc/schemas/api.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1762,6 +1762,7 @@ export const config = {
17621762
muxGovernorUrl: z.string().nullable(),
17631763
muxGovernorEnrolled: z.boolean(),
17641764
llmDebugLogs: z.boolean(),
1765+
heartbeatDefaultPrompt: z.string().optional(),
17651766
onePasswordAccountName: z.string().nullish(),
17661767
}),
17671768
},
@@ -1841,6 +1842,14 @@ export const config = {
18411842
.strict(),
18421843
output: z.void(),
18431844
},
1845+
updateHeartbeatDefaultPrompt: {
1846+
input: z
1847+
.object({
1848+
defaultPrompt: z.string().nullish(),
1849+
})
1850+
.strict(),
1851+
output: z.void(),
1852+
},
18441853
unenrollMuxGovernor: {
18451854
input: z.void(),
18461855
output: z.void(),

src/common/types/project.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ export interface ProjectsConfig {
8787
muxGatewayEnabled?: boolean;
8888
/** Enable recording AI SDK devtools logs to ~/.mux/sessions/<workspace>/devtools.jsonl */
8989
llmDebugLogs?: boolean;
90+
/** Default heartbeat prompt used when a workspace heartbeat does not set its own message. */
91+
heartbeatDefaultPrompt?: string;
9092
muxGatewayModels?: string[];
9193
routePriority?: string[];
9294
routeOverrides?: Record<string, string>;

src/node/config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -705,6 +705,7 @@ export class Config {
705705
taskSettings,
706706
muxGatewayEnabled,
707707
llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs),
708+
heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt),
708709
muxGatewayModels,
709710
routePriority,
710711
routeOverrides,
@@ -771,6 +772,11 @@ export class Config {
771772
data.llmDebugLogs = llmDebugLogs;
772773
}
773774

775+
const heartbeatDefaultPrompt = parseOptionalNonEmptyString(config.heartbeatDefaultPrompt);
776+
if (heartbeatDefaultPrompt) {
777+
data.heartbeatDefaultPrompt = heartbeatDefaultPrompt;
778+
}
779+
774780
const muxGatewayModels = parseOptionalStringArray(config.muxGatewayModels);
775781
if (muxGatewayModels !== undefined) {
776782
data.muxGatewayModels = muxGatewayModels;

src/node/orpc/router.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,7 @@ export const router = (authToken?: string) => {
592592
muxGovernorUrl,
593593
muxGovernorEnrolled,
594594
llmDebugLogs: config.llmDebugLogs === true,
595+
heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined,
595596
onePasswordAccountName: config.onePasswordAccountName ?? null,
596597
};
597598
}),
@@ -960,6 +961,20 @@ export const router = (authToken?: string) => {
960961
return config;
961962
});
962963
}),
964+
updateHeartbeatDefaultPrompt: t
965+
.input(schemas.config.updateHeartbeatDefaultPrompt.input)
966+
.output(schemas.config.updateHeartbeatDefaultPrompt.output)
967+
.handler(async ({ context, input }) => {
968+
await context.config.editConfig((config) => {
969+
const trimmed = input.defaultPrompt?.trim();
970+
if (trimmed && trimmed.length > 0) {
971+
config.heartbeatDefaultPrompt = trimmed;
972+
} else {
973+
delete config.heartbeatDefaultPrompt;
974+
}
975+
return config;
976+
});
977+
}),
963978
unenrollMuxGovernor: t
964979
.input(schemas.config.unenrollMuxGovernor.input)
965980
.output(schemas.config.unenrollMuxGovernor.output)

0 commit comments

Comments
 (0)