Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/browser/features/Settings/Sections/GeneralSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -17,6 +18,7 @@ interface MockConfig {
coderWorkspaceArchiveBehavior: CoderWorkspaceArchiveBehavior;
worktreeArchiveBehavior: WorktreeArchiveBehavior;
llmDebugLogs: boolean;
heartbeatDefaultPrompt?: string;
}

interface MockAPIClient {
Expand All @@ -27,6 +29,7 @@ interface MockAPIClient {
worktreeArchiveBehavior: WorktreeArchiveBehavior;
}) => Promise<void>;
updateLlmDebugLogs: (input: { enabled: boolean }) => Promise<void>;
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => Promise<void>;
};
server: {
getSshHost: () => Promise<string | null>;
Expand Down Expand Up @@ -213,6 +216,15 @@ function createMockAPI(configOverrides: Partial<MockConfig> = {}): 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)),
Expand All @@ -233,6 +245,7 @@ describe("GeneralSection", () => {

beforeEach(() => {
cleanupDom = installDom();
spyOn(ExperimentsModule, "useExperimentValue").mockImplementation(() => true);
});

afterEach(() => {
Expand Down Expand Up @@ -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,
Expand Down
77 changes: 77 additions & 0 deletions src/browser/features/Settings/Sections/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<EditorType> = new Set([
Expand Down Expand Up @@ -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<LaunchBehavior>(
LAUNCH_BEHAVIOR_KEY,
"dashboard"
Expand Down Expand Up @@ -203,18 +207,23 @@ 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<CoderWorkspaceArchiveBehavior>(DEFAULT_CODER_ARCHIVE_BEHAVIOR);
const worktreeArchiveBehaviorRef = useRef<WorktreeArchiveBehavior>(
DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR
);

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<void>>(Promise.resolve());
const llmDebugLogsUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const heartbeatDefaultPromptUpdateChainRef = useRef<Promise<void>>(Promise.resolve());
const archiveBehaviorPendingUpdateRef = useRef<CoderWorkspaceArchiveBehavior | undefined>(
undefined
);
Expand All @@ -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()
Expand All @@ -256,13 +268,25 @@ 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) {
// Fall back to the safe defaults already in state so the controls can recover after a
// 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]);

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -559,6 +612,30 @@ export function GeneralSection() {
aria-label="Toggle API Debug Logs"
/>
</div>
{workspaceHeartbeatsEnabled ? (
<div className="py-3">
<label htmlFor="heartbeat-default-prompt" className="block">
<div className="text-foreground text-sm">Default heartbeat prompt</div>
<div className="text-muted mt-0.5 text-xs">
Used for workspace heartbeats when a workspace does not set its own message.
</div>
</label>
<textarea
id="heartbeat-default-prompt"
rows={4}
value={heartbeatDefaultPrompt}
onChange={(event: React.ChangeEvent<HTMLTextAreaElement>) => {
heartbeatDefaultPromptLoadNonceRef.current++;
setHeartbeatDefaultPromptLoaded(true);
setHeartbeatDefaultPrompt(event.target.value);
}}
onBlur={handleHeartbeatDefaultPromptBlur}
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"
placeholder={HEARTBEAT_DEFAULT_MESSAGE_BODY}
aria-label="Default heartbeat prompt"
/>
</div>
) : null}
</div>
</div>

Expand Down
13 changes: 13 additions & 0 deletions src/browser/stories/mocks/orpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,8 @@ export interface MockORPCClientOptions {
defaultRuntime?: RuntimeEnablementId | null;
/** Initial 1Password account name for config.getConfig */
onePasswordAccountName?: string | null;
/** Initial global heartbeat default prompt for config.getConfig */
heartbeatDefaultPrompt?: string;
/** Initial route priority for config.getConfig */
routePriority?: string[];
/** Initial per-model route overrides for config.getConfig */
Expand Down Expand Up @@ -356,6 +358,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
runtimeEnablement: initialRuntimeEnablement,
defaultRuntime: initialDefaultRuntime,
onePasswordAccountName: initialOnePasswordAccountName = null,
heartbeatDefaultPrompt: initialHeartbeatDefaultPrompt,
routePriority: initialRoutePriority = ["direct"],
routeOverrides: initialRouteOverrides = {},
agentDefinitions: initialAgentDefinitions,
Expand Down Expand Up @@ -545,6 +548,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl

let defaultRuntime: RuntimeEnablementId | null = initialDefaultRuntime ?? null;
let onePasswordAccountName: string | null = initialOnePasswordAccountName;
let heartbeatDefaultPrompt = initialHeartbeatDefaultPrompt;
let routePriority = [...initialRoutePriority];
let routeOverrides = { ...initialRouteOverrides };
const configChangeSubscribers = new Set<(value: void) => void>();
Expand Down Expand Up @@ -735,7 +739,9 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
subagentAiDefaults,
muxGovernorUrl,
onePasswordAccountName,
heartbeatDefaultPrompt,
muxGovernorEnrolled,
llmDebugLogs: false,
}),
saveConfig: (input: {
taskSettings: unknown;
Expand Down Expand Up @@ -819,6 +825,13 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl
notifyConfigChanged();
return Promise.resolve(undefined);
},
updateHeartbeatDefaultPrompt: (input: { defaultPrompt?: string | null }) => {
heartbeatDefaultPrompt = input.defaultPrompt?.trim()
? input.defaultPrompt.trim()
: undefined;
notifyConfigChanged();
return Promise.resolve(undefined);
},
updateRuntimeEnablement: (input: {
projectPath?: string | null;
runtimeEnablement?: Record<string, boolean> | null;
Expand Down
1 change: 1 addition & 0 deletions src/common/config/schemas/appConfigOnDisk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const AppConfigOnDiskSchema = z
taskSettings: TaskSettingsSchema.optional(),
muxGatewayEnabled: z.boolean().optional(),
llmDebugLogs: z.boolean().optional(),
heartbeatDefaultPrompt: z.string().optional(),
muxGatewayModels: z.array(z.string()).optional(),
routePriority: z.array(z.string()).optional(),
routeOverrides: z.record(z.string(), z.string()).optional(),
Expand Down
9 changes: 9 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1762,6 +1762,7 @@ export const config = {
muxGovernorUrl: z.string().nullable(),
muxGovernorEnrolled: z.boolean(),
llmDebugLogs: z.boolean(),
heartbeatDefaultPrompt: z.string().optional(),
onePasswordAccountName: z.string().nullish(),
}),
},
Expand Down Expand Up @@ -1841,6 +1842,14 @@ export const config = {
.strict(),
output: z.void(),
},
updateHeartbeatDefaultPrompt: {
input: z
.object({
defaultPrompt: z.string().nullish(),
})
.strict(),
output: z.void(),
},
unenrollMuxGovernor: {
input: z.void(),
output: z.void(),
Expand Down
2 changes: 2 additions & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@ export interface ProjectsConfig {
muxGatewayEnabled?: boolean;
/** Enable recording AI SDK devtools logs to ~/.mux/sessions/<workspace>/devtools.jsonl */
llmDebugLogs?: boolean;
/** Default heartbeat prompt used when a workspace heartbeat does not set its own message. */
heartbeatDefaultPrompt?: string;
muxGatewayModels?: string[];
routePriority?: string[];
routeOverrides?: Record<string, string>;
Expand Down
6 changes: 6 additions & 0 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -705,6 +705,7 @@ export class Config {
taskSettings,
muxGatewayEnabled,
llmDebugLogs: parseOptionalBoolean(parsed.llmDebugLogs),
heartbeatDefaultPrompt: parseOptionalNonEmptyString(parsed.heartbeatDefaultPrompt),
muxGatewayModels,
routePriority,
routeOverrides,
Expand Down Expand Up @@ -771,6 +772,11 @@ export class Config {
data.llmDebugLogs = llmDebugLogs;
}

const heartbeatDefaultPrompt = parseOptionalNonEmptyString(config.heartbeatDefaultPrompt);
if (heartbeatDefaultPrompt) {
data.heartbeatDefaultPrompt = heartbeatDefaultPrompt;
}

const muxGatewayModels = parseOptionalStringArray(config.muxGatewayModels);
if (muxGatewayModels !== undefined) {
data.muxGatewayModels = muxGatewayModels;
Expand Down
15 changes: 15 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,7 @@ export const router = (authToken?: string) => {
muxGovernorUrl,
muxGovernorEnrolled,
llmDebugLogs: config.llmDebugLogs === true,
heartbeatDefaultPrompt: config.heartbeatDefaultPrompt ?? undefined,
onePasswordAccountName: config.onePasswordAccountName ?? null,
};
}),
Expand Down Expand Up @@ -960,6 +961,20 @@ export const router = (authToken?: string) => {
return config;
});
}),
updateHeartbeatDefaultPrompt: t
.input(schemas.config.updateHeartbeatDefaultPrompt.input)
.output(schemas.config.updateHeartbeatDefaultPrompt.output)
.handler(async ({ context, input }) => {
await context.config.editConfig((config) => {
const trimmed = input.defaultPrompt?.trim();
if (trimmed && trimmed.length > 0) {
config.heartbeatDefaultPrompt = trimmed;
} else {
delete config.heartbeatDefaultPrompt;
}
return config;
});
}),
unenrollMuxGovernor: t
.input(schemas.config.unenrollMuxGovernor.input)
.output(schemas.config.unenrollMuxGovernor.output)
Expand Down
Loading
Loading