summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-29 21:40:39 +0900
committerAdam Malczewski <[email protected]>2026-06-29 21:40:39 +0900
commit5a7a30ffbfcbcae3ae3049dc6330468a049fa9ba (patch)
treee0d2aaadccbdfebea48d4d7b947295eb964a240c /src
parent1cdb866fbb708a1f6c5e802a075789cece85800d (diff)
downloaddispatch-web-5a7a30ffbfcbcae3ae3049dc6330468a049fa9ba.tar.gz
dispatch-web-5a7a30ffbfcbcae3ae3049dc6330468a049fa9ba.zip
feat(heartbeat): add inactiveOnly checkbox (skip fires while workspace is busy)
Add the frontend UI for the backend's per-workspace heartbeat `inactiveOnly` setting (default on): when on, the heartbeat skips a scheduled fire whenever the configured workspace has any active agents (a conversation whose status is "active" or "queued"); it stays quiet while the user is actively working and only fires when the workspace is idle. When off, the heartbeat fires unconditionally on every interval (the pre-existing behavior). Contract (backend handoff: notes/heartbeat-setting-handoff.md): - GET /workspaces/:id/heartbeat now includes inactiveOnly: boolean - PUT /workspaces/:id/heartbeat accepts a partial { inactiveOnly?: boolean } (absent = unchanged; explicit false = opt-out; non-boolean → 400) The heartbeat shapes are FE-owned locally (consumer-defines-port — not in @dispatch/transport-contract; verified the mirror has no heartbeat symbol), so this is a FE-local type + view-model + UI change — no pinned file: dep bump or .dispatch mirror regen needed. The network seam (store.svelte.ts) needed no change: setHeartbeatConfig already JSON.stringify's the patch verbatim and normalizes the response via normalizeHeartbeatConfig (now producing the field). Implementation (pure core / injected shell): - types.ts: inactiveOnly on HeartbeatConfig + HeartbeatConfigPatch - view-model.ts (pure): inactiveOnly in HeartbeatFormState; formFromConfig/ emptyForm/patchFromForm/formDiffers carry it; normalizeHeartbeatConfig coerces with `d.inactiveOnly !== false` (default ON — mirrors the backend config-store deserializer; only explicit false opts out, so a legacy config persisted before the field reads back as true) - HeartbeatView.svelte: a checkbox (default checked) "Only run when idle" as the first config section, save-on-change (mirrors the enable toggle): a partial PUT { inactiveOnly } on toggle; a failed save reverts + surfaces the error inline Tests: new HeartbeatView.test.ts (renders checked/unchecked, partial-patch PUT, failed-save revert); view-model.test.ts form/normalize/diff coverage; updated PromptEditor.test.ts + store.test.ts echo fixtures for the new required field. Verification: typecheck 0 errors; test 1133 passing (stable x2); biome clean; build succeeds. Live read-only check: the running main backend (not the feature worktree backend) has no inactiveOnly yet; the FE's defensive default renders the checkbox checked and degrades gracefully — no crash. No backend was booted by the agent. backend-handoff.md §2l records the consumed contract + FE status.
Diffstat (limited to 'src')
-rw-r--r--src/app/store.test.ts1
-rw-r--r--src/features/heartbeat/logic/types.ts10
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts57
-rw-r--r--src/features/heartbeat/logic/view-model.ts9
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte46
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.test.ts203
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts1
7 files changed, 327 insertions, 0 deletions
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index a945ea7..7c9de52 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1223,6 +1223,7 @@ describe("createAppStore", () => {
if (!result.ok) throw new Error("unreachable");
expect(result.config).toEqual({
enabled: true,
+ inactiveOnly: true,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 15,
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
index 3d3d525..83cec74 100644
--- a/src/features/heartbeat/logic/types.ts
+++ b/src/features/heartbeat/logic/types.ts
@@ -26,6 +26,15 @@ export type HeartbeatRunStatus = "running" | "completed" | "stopped";
export interface HeartbeatConfig {
/** Whether the autonomous loop is enabled (running on the interval). */
readonly enabled: boolean;
+ /**
+ * When true (the default), the heartbeat SKIPS a fire whenever the configured
+ * workspace has any active agents (a conversation whose persisted status is
+ * `"active"` or `"queued"`) — it stays quiet while the user is actively
+ * working and only fires when the workspace is idle. When false, the heartbeat
+ * fires unconditionally on every interval. The heartbeat-spawned conversation
+ * lives in a dedicated workspace, so an in-flight run never self-blocks.
+ */
+ readonly inactiveOnly: boolean;
readonly systemPrompt: string;
readonly taskPrompt: string;
/** Minutes between runs. */
@@ -46,6 +55,7 @@ export interface HeartbeatConfig {
*/
export interface HeartbeatConfigPatch {
readonly enabled?: boolean;
+ readonly inactiveOnly?: boolean;
readonly systemPrompt?: string;
readonly taskPrompt?: string;
readonly intervalMinutes?: number;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index aca0aa6..c9ef118 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -39,6 +39,7 @@ const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
enabled: false,
+ inactiveOnly: true,
systemPrompt: "be helpful",
taskPrompt: "check status",
intervalMinutes: 15,
@@ -254,6 +255,40 @@ describe("config form", () => {
const f = formFromConfig(c);
expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
});
+
+ it("emptyForm defaults inactiveOnly to true (on by default)", () => {
+ expect(emptyForm().inactiveOnly).toBe(true);
+ });
+
+ it("formFromConfig carries inactiveOnly through verbatim", () => {
+ expect(formFromConfig(config({ inactiveOnly: true })).inactiveOnly).toBe(true);
+ expect(formFromConfig(config({ inactiveOnly: false })).inactiveOnly).toBe(false);
+ });
+
+ it("formFromConfig coerces a missing/malformed inactiveOnly to the default (true)", () => {
+ // A legacy config (undefined) or a non-boolean is read as ON (true) — matches
+ // normalizeHeartbeatConfig's default and the backend's "on by default".
+ const f = formFromConfig(config({ inactiveOnly: undefined as unknown as boolean }));
+ expect(f.inactiveOnly).toBe(true);
+ });
+
+ it("patchFromForm carries inactiveOnly", () => {
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: false }))).inactiveOnly).toBe(false);
+ expect(patchFromForm(formFromConfig(config({ inactiveOnly: true }))).inactiveOnly).toBe(true);
+ });
+
+ it("formDiffers is true after toggling inactiveOnly", () => {
+ const c = config({ inactiveOnly: true });
+ const f = formFromConfig(c);
+ f.inactiveOnly = false;
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is false for a form seeded from the config (inactiveOnly unchanged)", () => {
+ const c = config({ inactiveOnly: false });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
});
describe("system-prompt inheritance (override ⇄ global default)", () => {
@@ -321,6 +356,7 @@ describe("normalizeHeartbeatConfig", () => {
it("passes through a well-formed config", () => {
const c = normalizeHeartbeatConfig({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -329,6 +365,7 @@ describe("normalizeHeartbeatConfig", () => {
});
expect(c).toEqual({
enabled: true,
+ inactiveOnly: false,
systemPrompt: "sys",
taskPrompt: "task",
intervalMinutes: 20,
@@ -339,10 +376,12 @@ describe("normalizeHeartbeatConfig", () => {
it("coerces a malformed body safely (never throws, never undefined)", () => {
const c = normalizeHeartbeatConfig({
enabled: "yes",
+ inactiveOnly: "yes",
intervalMinutes: -3,
reasoningEffort: "bogus",
});
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // non-boolean → default ON
expect(c.intervalMinutes).toBe(1);
expect(c.reasoningEffort).toBeNull();
expect(c.systemPrompt).toBe("");
@@ -355,12 +394,30 @@ describe("normalizeHeartbeatConfig", () => {
it("handles null / non-object input", () => {
const c = normalizeHeartbeatConfig(null);
expect(c.enabled).toBe(false);
+ expect(c.inactiveOnly).toBe(true); // default ON for an absent config
expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
expect(c.model).toBe("");
});
it("clamps a huge interval", () => {
expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
});
+ it("inactiveOnly defaults to true when absent (legacy config → on by default)", () => {
+ // A config persisted by an older backend (no inactiveOnly field) reads back
+ // as true — the feature is ON by default for everyone.
+ expect(normalizeHeartbeatConfig({}).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: undefined }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly passes through an explicit false (opt-out)", () => {
+ expect(normalizeHeartbeatConfig({ inactiveOnly: false }).inactiveOnly).toBe(false);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: true }).inactiveOnly).toBe(true);
+ });
+ it("inactiveOnly treats only an explicit boolean false as false (not 0, not null)", () => {
+ // The wire contract requires a JSON boolean; a non-boolean (0, null, "no")
+ // is treated as the default (true) rather than silently misbehaving.
+ expect(normalizeHeartbeatConfig({ inactiveOnly: 0 }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: null }).inactiveOnly).toBe(true);
+ expect(normalizeHeartbeatConfig({ inactiveOnly: "false" }).inactiveOnly).toBe(true);
+ });
});
describe("normalizeHeartbeatRuns", () => {
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index e91febd..4a5ba7f 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -220,6 +220,7 @@ export function approximateNextRunEpoch(
*/
export interface HeartbeatFormState {
enabled: boolean;
+ inactiveOnly: boolean;
systemPrompt: string;
taskPrompt: string;
intervalHours: number;
@@ -254,6 +255,7 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
const { hours, minutes } = splitInterval(config.intervalMinutes);
return {
enabled: config.enabled === true,
+ inactiveOnly: config.inactiveOnly !== false,
systemPrompt: config.systemPrompt ?? "",
taskPrompt: config.taskPrompt ?? "",
intervalHours: hours,
@@ -268,6 +270,7 @@ export function emptyForm(): HeartbeatFormState {
const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
return {
enabled: false,
+ inactiveOnly: true,
systemPrompt: "",
taskPrompt: "",
intervalHours: hours,
@@ -295,6 +298,7 @@ export function normalizeInterval(value: unknown): number {
export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
return {
enabled: form.enabled,
+ inactiveOnly: form.inactiveOnly,
systemPrompt: form.systemPrompt,
taskPrompt: form.taskPrompt,
intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
@@ -308,6 +312,7 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig):
const { hours, minutes } = splitInterval(config.intervalMinutes);
return (
form.enabled !== config.enabled ||
+ form.inactiveOnly !== config.inactiveOnly ||
form.systemPrompt !== (config.systemPrompt ?? "") ||
form.taskPrompt !== (config.taskPrompt ?? "") ||
form.intervalHours !== hours ||
@@ -390,6 +395,10 @@ export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
const effort = d.reasoningEffort;
return {
enabled: d.enabled === true,
+ // Default ON (true): a missing/falsey-but-not-false field (a legacy config
+ // persisted before the field shipped) reads back as inactiveOnly: true —
+ // the feature is on by default for everyone. Only an explicit `false` opts out.
+ inactiveOnly: d.inactiveOnly !== false,
systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
intervalMinutes: normalizeInterval(d.intervalMinutes),
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
index 5f262f8..7f95c40 100644
--- a/src/features/heartbeat/ui/HeartbeatView.svelte
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -140,6 +140,31 @@
}
}
+ // The inactive-only checkbox is a save-on-change control (like the enable
+ // toggle): a partial PUT { inactiveOnly } — no need to round-trip the rest
+ // of the form. The heartbeat then skips a fire whenever the workspace has
+ // active agents (a conversation whose status is "active" or "queued").
+ async function handleToggleInactiveOnly(): Promise<void> {
+ if (saving) return;
+ const next = !form.inactiveOnly;
+ form = { ...form, inactiveOnly: next };
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig({ inactiveOnly: next });
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ justSaved = true;
+ } else {
+ saveError = result.error;
+ // Revert the checkbox to the last-known state.
+ form = { ...form, inactiveOnly: loadedConfig.inactiveOnly };
+ }
+ }
+
// ── Runs list (polls while mounted) ───────────────────────────────────────
let runs = $state<readonly HeartbeatRunView[]>([]);
/** The raw backend runs (carry `triggeredAt`), kept for the next-run
@@ -323,6 +348,27 @@
{#if configError}
<p class="text-xs text-error">{configError}</p>
{:else}
+ <!-- Inactive-only (skip fires while the workspace has active agents) -->
+ <section class="flex flex-col gap-1">
+ <label class="flex items-start gap-2 text-sm">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm checkbox-primary mt-0.5"
+ checked={form.inactiveOnly}
+ disabled={saving || configLoading}
+ onchange={handleToggleInactiveOnly}
+ aria-label="Only run the heartbeat when the workspace is idle"
+ />
+ <span class="flex flex-col gap-0.5">
+ <span>Only run when idle</span>
+ <span class="text-xs opacity-50">
+ Skip heartbeat fires while agents are active in this workspace. When off, the
+ heartbeat runs on every interval regardless of activity.
+ </span>
+ </span>
+ </label>
+ </section>
+
<!-- Prompts (open the full-page editor) -->
<section class="flex flex-col gap-1">
<span class="text-xs font-semibold uppercase opacity-60">Prompts</span>
diff --git a/src/features/heartbeat/ui/HeartbeatView.test.ts b/src/features/heartbeat/ui/HeartbeatView.test.ts
new file mode 100644
index 0000000..89eeee3
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.test.ts
@@ -0,0 +1,203 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { LoadSystemPrompt, LoadSystemPromptVariables } from "../../system-prompt";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+} from "../logic/types";
+import HeartbeatView from "./HeartbeatView.svelte";
+
+// ── Fakes for the injected ports ─────────────────────────────────────────────
+// Only the OUTERMOST edges are faked (the save/load ports); no sibling module is
+// mocked. Mirrors the PromptEditor test's fake-port pattern.
+
+function makeConfig(over: Partial<HeartbeatConfig> = {}): HeartbeatConfig {
+ return {
+ enabled: false,
+ inactiveOnly: true,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: 30,
+ model: "",
+ reasoningEffort: null,
+ ...over,
+ };
+}
+
+/** A capturing saveConfig that echoes a merged config (so the form re-seeds). */
+function fakeSaveConfig(initial: HeartbeatConfig): {
+ calls: HeartbeatConfigPatch[];
+ impl: SaveHeartbeatConfig;
+} {
+ const calls: HeartbeatConfigPatch[] = [];
+ let current = initial;
+ const impl: SaveHeartbeatConfig = async (patch) => {
+ calls.push(patch);
+ // Echo the merged config so the component re-seeds from the server response.
+ current = { ...current, ...patch };
+ return { ok: true, config: current } satisfies HeartbeatConfigResult;
+ };
+ return { calls, impl };
+}
+
+function fakeLoadConfig(config: HeartbeatConfig): LoadHeartbeatConfig {
+ return vi.fn(async () => ({ ok: true, config }) as const);
+}
+
+function fakeLoadRuns(): LoadHeartbeatRuns {
+ return vi.fn(async () => ({ ok: true, runs: [] }) as const);
+}
+
+function fakeStopRun(): StopHeartbeatRun {
+ return vi.fn(async () => ({ ok: true }) as const satisfies HeartbeatStopResult);
+}
+
+function fakeLoadNextRun(): LoadHeartbeatNextRun {
+ // No scheduled run (heartbeat disabled in the default config) → no countdown.
+ return vi.fn(
+ async () => ({ ok: true, nextRunAt: null }) as const satisfies HeartbeatNextRunResult,
+ );
+}
+
+function fakeLoadVariables(): LoadSystemPromptVariables {
+ return vi.fn(async () => ({ ok: true, variables: [] }) as const);
+}
+
+function fakeLoadDefaultPrompt(): LoadSystemPrompt {
+ return vi.fn(async () => ({ ok: true, template: "" }) as const);
+}
+
+const baseProps = (overrides: Record<string, unknown> = {}) => ({
+ models: [] as readonly string[],
+ loadConfig: fakeLoadConfig(makeConfig()),
+ saveConfig: fakeSaveConfig(makeConfig()).impl,
+ loadVariables: fakeLoadVariables(),
+ loadDefaultPrompt: fakeLoadDefaultPrompt(),
+ loadRuns: fakeLoadRuns(),
+ stopRun: fakeStopRun(),
+ loadNextRun: fakeLoadNextRun(),
+ onOpenRun: vi.fn(),
+ ...overrides,
+});
+
+// HeartbeatView sets up polling intervals (runs + next-run + clock) on mount.
+// Clear any stray timers between tests so a later test never hangs on a leaked
+// interval (the $effect cleanup clears them on unmount; this is belt+suspenders).
+afterEach(() => {
+ vi.clearAllTimers();
+});
+
+describe("HeartbeatView — inactive-only checkbox", () => {
+ it("renders checked when the loaded config has inactiveOnly: true (the default)", async () => {
+ const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: true }));
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+ });
+
+ it("renders unchecked when the loaded config has inactiveOnly: false", async () => {
+ const loadConfig = fakeLoadConfig(makeConfig({ inactiveOnly: false }));
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).not.toBeChecked();
+ });
+
+ it("toggling the checkbox persists a PARTIAL patch { inactiveOnly } and re-seeds", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: true });
+ const save = fakeSaveConfig(initial);
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: save.impl }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+
+ await user.click(checkbox);
+
+ // The save port was called with ONLY { inactiveOnly: false } — a partial
+ // update, not the whole config (mirrors the enable toggle's partial PUT).
+ await vi.waitFor(() => {
+ expect(save.calls).toHaveLength(1);
+ });
+ expect(save.calls[0]).toEqual({ inactiveOnly: false });
+
+ // After the save resolves, the checkbox reflects the server response (unchecked).
+ await vi.waitFor(() => {
+ expect(checkbox).not.toBeChecked();
+ });
+ });
+
+ it("toggling back on sends { inactiveOnly: true }", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: false });
+ const save = fakeSaveConfig(initial);
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: save.impl }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).not.toBeChecked();
+
+ await user.click(checkbox);
+
+ await vi.waitFor(() => {
+ expect(save.calls).toHaveLength(1);
+ });
+ expect(save.calls[0]).toEqual({ inactiveOnly: true });
+ await vi.waitFor(() => {
+ expect(checkbox).toBeChecked();
+ });
+ });
+
+ it("a failed save reverts the checkbox to the last-known state", async () => {
+ const user = userEvent.setup();
+ const initial = makeConfig({ inactiveOnly: true });
+ const failingSave: SaveHeartbeatConfig = async () => ({
+ ok: false,
+ error: "boom",
+ });
+ const loadConfig = fakeLoadConfig(initial);
+ render(HeartbeatView, {
+ props: baseProps({ loadConfig, saveConfig: failingSave }),
+ });
+
+ const checkbox = await screen.findByLabelText(
+ "Only run the heartbeat when the workspace is idle",
+ );
+ expect(checkbox).toBeChecked();
+
+ await user.click(checkbox);
+
+ // The failed save surfaces the error AND reverts the checkbox (stays checked).
+ await vi.waitFor(() => {
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ });
+ expect(checkbox).toBeChecked();
+ });
+});
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
index 284b319..c4bd3b8 100644
--- a/src/features/heartbeat/ui/PromptEditor.test.ts
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -29,6 +29,7 @@ function fakeSaveConfig(): {
// Echo a config that reflects the persisted patch (so onSaved sync is realistic).
const config = {
enabled: false,
+ inactiveOnly: true,
systemPrompt: patch.systemPrompt ?? "",
taskPrompt: patch.taskPrompt ?? "",
intervalMinutes: 30,