summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui
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/features/heartbeat/ui
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/features/heartbeat/ui')
-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
3 files changed, 250 insertions, 0 deletions
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,