summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/heartbeat/ui')
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte582
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.test.ts203
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte412
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts168
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte176
5 files changed, 1541 insertions, 0 deletions
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
new file mode 100644
index 0000000..7f95c40
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -0,0 +1,582 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import { isReasoningEffort } from "../../chat/reasoning-effort";
+ import {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ type Badge,
+ emptyForm,
+ effortOptions,
+ formatCountdown,
+ formDiffers,
+ formFromConfig,
+ joinInterval,
+ nextRunEpoch,
+ patchFromForm,
+ viewRuns,
+ type HeartbeatFormState,
+ type HeartbeatRunView,
+ } from "../logic/view-model";
+ import type {
+ HeartbeatRun,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+ } from "../logic/types";
+ import type {
+ LoadSystemPrompt,
+ LoadSystemPromptVariables,
+ } from "../../system-prompt";
+ import PromptEditor from "./PromptEditor.svelte";
+
+ let {
+ models,
+ loadConfig,
+ saveConfig,
+ loadRuns,
+ stopRun,
+ loadVariables,
+ loadDefaultPrompt,
+ loadNextRun,
+ onOpenRun,
+ }: {
+ /** The available model names (for the config's model dropdown). */
+ models: readonly string[];
+ loadConfig: LoadHeartbeatConfig;
+ saveConfig: SaveHeartbeatConfig;
+ loadRuns: LoadHeartbeatRuns;
+ stopRun: StopHeartbeatRun;
+ /** Load the available system-prompt variables (palette in the prompt editor). */
+ loadVariables: LoadSystemPromptVariables;
+ /** Load the global system prompt — the default the heartbeat inherits when
+ * its `systemPrompt` is empty (the workspace's regular prompt). */
+ loadDefaultPrompt: LoadSystemPrompt;
+ /** Load the server-authoritative next-run timestamp (the countdown source). */
+ loadNextRun: LoadHeartbeatNextRun;
+ /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */
+ onOpenRun: (run: HeartbeatRunView) => void;
+ } = $props();
+
+ const badgeClass: Record<Badge, string> = {
+ success: "badge-success",
+ warning: "badge-warning",
+ error: "badge-error",
+ neutral: "badge-ghost",
+ };
+
+ const effortOpts = effortOptions();
+
+ // ── Config form ──────────────────────────────────────────────────────────
+ let form = $state<HeartbeatFormState>(emptyForm());
+ /** The last successfully loaded/saved config, to diff the form against. */
+ let loadedConfig = $state<HeartbeatFormState>(emptyForm());
+ let configLoading = $state(false);
+ let configError = $state<string | null>(null);
+ let saving = $state(false);
+ let saveError = $state<string | null>(null);
+ let justSaved = $state(false);
+ let hasConfig = $state(false);
+ let promptEditorOpen = $state(false);
+
+ const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig);
+
+ async function refreshConfig(): Promise<void> {
+ configLoading = true;
+ configError = null;
+ const result = await loadConfig();
+ configLoading = false;
+ if (result === null) return;
+ if (result.ok) {
+ hasConfig = true;
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ saveError = null;
+ } else {
+ configError = result.error;
+ }
+ }
+
+ async function handleSave(): Promise<void> {
+ if (saving || !hasChanges) return;
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig(patchFromForm(form));
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Re-seed from the authoritative response so the form tracks the server.
+ form = formFromConfig(result.config);
+ loadedConfig = formFromConfig(result.config);
+ justSaved = true;
+ } else {
+ saveError = result.error;
+ }
+ }
+
+ // The enable toggle is the primary action — persist it immediately (don't
+ // require a separate Save). Mirrors the codebase's save-on-change controls.
+ async function handleToggleEnabled(): Promise<void> {
+ if (saving) return;
+ const next = !form.enabled;
+ form = { ...form, enabled: next };
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig({ enabled: 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 toggle to the last-known state.
+ form = { ...form, enabled: loadedConfig.enabled };
+ }
+ }
+
+ // 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
+ * approximation fallback (the view drops `triggeredAt` for display labels). */
+ let rawRuns = $state<readonly HeartbeatRun[]>([]);
+ /** True after the first successful load (gates the "No runs yet" empty state
+ * WITHOUT flashing it before the initial fetch resolves). The per-poll
+ * loading is intentionally INVISIBLE — it's near-instant and a visible
+ * loading indicator caused the sidebar to flicker every poll (height shift). */
+ let hasLoadedRuns = $state(false);
+ let runsError = $state<string | null>(null);
+ let stoppingId = $state<string | null>(null);
+ let stopError = $state<string | null>(null);
+ let pollHandle: ReturnType<typeof setInterval> | null = null;
+ /** Re-entrancy guard for background polling (no UI — prevents overlapping fetches). */
+ let refreshInFlight = false;
+
+ // ── Next-run countdown ───────────────────────────────────────────────────
+ /** Epoch-ms of the next scheduled run, or null (no countdown shown). Sourced
+ * from the backend's `next-run` endpoint; falls back to an approximation
+ * (latest run + interval) when the endpoint is unavailable (404 — pre-CR-HB-3). */
+ let nextRunAt = $state<number | null>(null);
+ /** Once the next-run endpoint fails (404), stop polling it (avoid 404 spam) and
+ * rely on the approximation. Reset only on remount. */
+ let nextRunEndpointFailed = $state(false);
+
+ async function refreshNextRun(): Promise<void> {
+ if (nextRunEndpointFailed) return;
+ const result = await loadNextRun();
+ if (result === null) return;
+ if (result.ok) {
+ nextRunAt = nextRunEpoch(result.nextRunAt);
+ } else {
+ // Endpoint absent / errored → stop polling it + use the approximation.
+ nextRunEndpointFailed = true;
+ }
+ }
+
+ /** The fallback countdown source: latest run + interval (only when enabled +
+ * ≥1 run). Recomputed reactively from the loaded config + raw runs. */
+ const approxNextRun = $derived(
+ approximateNextRunEpoch(
+ rawRuns,
+ joinInterval(loadedConfig.intervalHours, loadedConfig.intervalMinutes),
+ loadedConfig.enabled,
+ ),
+ );
+ /** The effective next-run epoch: the server value if available, else the
+ * approximation. Drives the countdown. */
+ const effectiveNextRun = $derived(nextRunEndpointFailed ? approxNextRun : nextRunAt);
+
+ const RUN_POLL_MS = 4000;
+
+ async function refreshRuns(): Promise<void> {
+ if (refreshInFlight) return;
+ refreshInFlight = true;
+ const result = await loadRuns();
+ refreshInFlight = false;
+ if (result === null) return;
+ if (result.ok) {
+ rawRuns = result.runs;
+ runs = viewRuns(result.runs);
+ // Clear the error only on success so it stays visible (stable, no
+ // flicker) during an in-flight retry rather than vanishing mid-poll.
+ runsError = null;
+ hasLoadedRuns = true;
+ } else {
+ runsError = result.error;
+ }
+ }
+
+ async function handleStop(runId: string): Promise<void> {
+ if (stoppingId !== null) return;
+ stoppingId = runId;
+ stopError = null;
+ const result = await stopRun(runId);
+ stoppingId = null;
+ if (result === null) return;
+ if (result.ok) {
+ await refreshRuns();
+ } else {
+ stopError = result.error;
+ }
+ }
+
+ // Load config + runs + next-run on mount, and poll them while the view is
+ // alive so a running run's completion/stopped transition + the next-run timer
+ // stay fresh without a manual refresh.
+ $effect(() => {
+ untrack(() => {
+ void refreshConfig();
+ void refreshRuns();
+ void refreshNextRun();
+ });
+ pollHandle = setInterval(() => {
+ void refreshRuns();
+ void refreshNextRun();
+ }, RUN_POLL_MS);
+ return () => {
+ if (pollHandle !== null) clearInterval(pollHandle);
+ pollHandle = null;
+ };
+ });
+
+ // A relative label ("5m ago") drifts as time passes; re-derive runs every
+ // minute so the list stays fresh without a full re-fetch.
+ let tick = $state(0);
+ $effect(() => {
+ const h = setInterval(() => {
+ tick++;
+ }, 60000);
+ return () => clearInterval(h);
+ });
+ const runsView = $derived.by(() => {
+ void tick; // depend on the ticker
+ return runs;
+ });
+
+ // The countdown clock: ticks every second so the "next run in Xm Ys" stays
+ // live. Pure countdown math is in `formatCountdown` (view-model); this only
+ // advances `now`.
+ let now = $state(Date.now());
+ $effect(() => {
+ const h = setInterval(() => {
+ now = Date.now();
+ }, 1000);
+ return () => clearInterval(h);
+ });
+ const countdownMs = $derived(
+ effectiveNextRun !== null ? effectiveNextRun - now : null,
+ );
+ const countdownLabel = $derived(formatCountdown(countdownMs));
+</script>
+
+<div class="flex flex-col gap-3">
+ <!-- Enable / status header -->
+ <section class="flex flex-col gap-1">
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <button
+ type="button"
+ role="switch"
+ aria-checked={form.enabled}
+ aria-label="Toggle heartbeat"
+ class="toggle toggle-sm"
+ class:toggle-primary={form.enabled}
+ disabled={saving || configLoading}
+ onclick={handleToggleEnabled}
+ ></button>
+ <span class="text-xs font-semibold uppercase opacity-60">
+ {#if configLoading}
+ Loading…
+ {:else if form.enabled}
+ Enabled
+ {:else}
+ Disabled
+ {/if}
+ </span>
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={configLoading}
+ onclick={() => refreshConfig()}
+ aria-label="Refresh heartbeat config"
+ >
+ {#if configLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+ {#if form.enabled && effectiveNextRun !== null}
+ <p class="text-xs opacity-60" title="When the next heartbeat run fires">
+ Next run in {countdownLabel}
+ </p>
+ {/if}
+ </section>
+
+ {#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>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={saving || configLoading}
+ onclick={() => (promptEditorOpen = true)}
+ >
+ Edit prompts
+ </button>
+ <p class="text-xs opacity-50">
+ Open the editor for the system + task prompts (with a variable palette).
+ </p>
+ </section>
+
+ <!-- Model + reasoning effort -->
+ <section class="flex flex-col gap-2">
+ <div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Model</span>
+ <select
+ class="select select-sm w-full"
+ value={form.model}
+ disabled={saving || configLoading}
+ onchange={(e) => (form = { ...form, model: e.currentTarget.value })}
+ aria-label="Heartbeat model"
+ >
+ {#if models.length === 0}
+ <option value="">No models available</option>
+ {:else}
+ <option value="" disabled>Select a model</option>
+ {#each models as model (model)}
+ <option value={model}>{model}</option>
+ {/each}
+ {/if}
+ </select>
+ </div>
+
+ <div class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Reasoning effort</span>
+ <select
+ class="select select-sm w-full"
+ value={form.reasoningEffort}
+ disabled={saving || configLoading}
+ onchange={(e) => {
+ const v = e.currentTarget.value;
+ if (isReasoningEffort(v)) form = { ...form, reasoningEffort: v as ReasoningEffort };
+ }}
+ aria-label="Heartbeat reasoning effort"
+ >
+ {#each effortOpts as option (option.value)}
+ <option value={option.value}>{option.label}</option>
+ {/each}
+ </select>
+ </div>
+ </section>
+
+ <!-- Interval (hours + minutes) -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Interval</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="24"
+ value={form.intervalHours}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n };
+ }}
+ onchange={(e) => {
+ const clamped = Math.max(0, Math.min(24, form.intervalHours));
+ form = { ...form, intervalHours: clamped };
+ e.currentTarget.value = String(clamped);
+ }}
+ aria-label="Heartbeat interval hours"
+ />
+ <span class="text-xs opacity-60">h</span>
+ <input
+ type="number"
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="59"
+ value={form.intervalMinutes}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n };
+ }}
+ onchange={(e) => {
+ const clamped = Math.max(0, Math.min(59, form.intervalMinutes));
+ form = { ...form, intervalMinutes: clamped };
+ e.currentTarget.value = String(clamped);
+ }}
+ aria-label="Heartbeat interval minutes"
+ />
+ <span class="text-xs opacity-60">m between runs</span>
+ </div>
+ </section>
+
+ <!-- Save -->
+ <section class="flex flex-col gap-1">
+ <button
+ type="button"
+ class="btn btn-sm btn-primary"
+ disabled={!hasChanges || saving || configLoading}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ Saving…
+ {:else}
+ Save config
+ {/if}
+ </button>
+ {#if saveError}
+ <p class="text-xs text-error">{saveError}</p>
+ {:else if justSaved}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ </section>
+ {/if}
+
+ <!-- Runs list -->
+ <section class="flex flex-col gap-1">
+ <div class="flex items-center justify-between gap-2">
+ <span class="text-xs font-semibold uppercase opacity-60">Runs</span>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ onclick={() => refreshRuns()}
+ aria-label="Refresh heartbeat runs"
+ >
+ Refresh
+ </button>
+ </div>
+
+ {#if runsError}
+ <p class="text-xs text-error">{runsError}</p>
+ {:else if runs.length > 0}
+ <ul class="flex max-h-72 flex-col gap-1 overflow-y-auto">
+ {#each runsView as run (run.id)}
+ <li>
+ <button
+ type="button"
+ class="flex w-full items-center justify-between gap-2 rounded-box bg-base-200 p-2 text-left hover:bg-base-300"
+ onclick={() => onOpenRun(run)}
+ aria-label="Open heartbeat run {run.id} chat"
+ >
+ <span class="flex min-w-0 flex-col gap-0.5">
+ <span class="truncate font-mono text-xs opacity-70">{run.id}</span>
+ <span class="text-xs opacity-60">
+ {run.relativeLabel} · {run.timeLabel}
+ </span>
+ </span>
+ <span class="flex items-center gap-1">
+ {#if run.busy}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ <span class="badge badge-sm {badgeClass[run.badge]}">{run.statusLabel}</span>
+ </span>
+ </button>
+ {#if run.busy}
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs mt-0.5 text-xs"
+ disabled={stoppingId === run.id}
+ onclick={() => handleStop(run.id)}
+ >
+ {#if stoppingId === run.id}
+ <span class="loading loading-spinner loading-xs"></span>
+ Stopping…
+ {:else}
+ Stop
+ {/if}
+ </button>
+ {/if}
+ </li>
+ {/each}
+ </ul>
+ {#if stopError}
+ <p class="text-xs text-error">{stopError}</p>
+ {/if}
+ {:else if hasLoadedRuns}
+ <!-- Loaded with zero runs (not the pre-first-load gap). No loading
+ indicator — polling is near-instant and a visible one flickered. -->
+ <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p>
+ {/if}
+ </section>
+</div>
+
+{#if promptEditorOpen}
+ <PromptEditor
+ systemPrompt={form.systemPrompt}
+ taskPrompt={form.taskPrompt}
+ {loadVariables}
+ {loadDefaultPrompt}
+ {saveConfig}
+ onSaved={(systemPrompt, taskPrompt) => {
+ // Sync the form + the diff baseline so the main Save button + formDiffers
+ // stay accurate (the editor persisted the prompts already). `systemPrompt`
+ // may be "" (inherit) — the form stores the raw override.
+ form = { ...form, systemPrompt, taskPrompt };
+ loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt };
+ justSaved = true;
+ }}
+ onClose={() => (promptEditorOpen = false)}
+ />
+{/if}
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.svelte b/src/features/heartbeat/ui/PromptEditor.svelte
new file mode 100644
index 0000000..2320827
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.svelte
@@ -0,0 +1,412 @@
+<script lang="ts">
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPrompt,
+ type LoadSystemPromptVariables,
+ } from "../../system-prompt";
+ import type { SaveHeartbeatConfig } from "../logic/types";
+ import {
+ effectiveSystemPrompt,
+ isInheritingSystemPrompt,
+ persistedSystemPrompt,
+ } from "../logic/view-model";
+ import { portal } from "../../../adapters/portal";
+
+ let {
+ systemPrompt,
+ taskPrompt,
+ loadVariables,
+ loadDefaultPrompt,
+ saveConfig,
+ onSaved,
+ onClose,
+ }: {
+ /**
+ * The heartbeat's persisted system prompt (raw override). Empty = inherit
+ * the global system prompt (the workspace's regular prompt).
+ */
+ systemPrompt: string;
+ /** The current task prompt (seeded from the loaded config). */
+ taskPrompt: string;
+ /** Load the available variables (`GET /system-prompt/variables`). */
+ loadVariables: LoadSystemPromptVariables;
+ /** Load the GLOBAL system prompt (`GET /system-prompt`) — the default the
+ * heartbeat inherits when its `systemPrompt` is empty. */
+ loadDefaultPrompt: LoadSystemPrompt;
+ /** Persist both prompts via a partial heartbeat config PUT. */
+ saveConfig: SaveHeartbeatConfig;
+ /** Called after a successful save with the RAW persisted prompts (system
+ * may be "" = inherit), so the parent can sync its form. */
+ onSaved: (systemPrompt: string, taskPrompt: string) => void;
+ onClose: () => void;
+ } = $props();
+
+ // The global default system prompt (loaded async on open). Empty until loaded
+ // (or when no global prompt is configured) — the editor degrades gracefully.
+ let defaultPrompt = $state("");
+
+ // The editable system text. Pre-filled with the EFFECTIVE prompt — the
+ // heartbeat's override, or the global default when inheriting (so the user
+ // can see + tweak what will run). A pre-filled default is NOT an explicit
+ // edit (see `hasChanges`).
+ let system = $state(untrack(() => systemPrompt));
+ let task = $state(untrack(() => taskPrompt));
+
+ // The raw persisted override at open + after each save (the diff baseline for
+ // the system field). Empty = the heartbeat is inheriting the global default.
+ // REACTIVE so a successful save can update it to the newly-persisted value —
+ // otherwise `systemBaseline` stays pinned to the open-time value and
+ // `hasChanges` never clears (the "Save flickers and reverts" bug).
+ let loadedSystemRaw = $state(untrack(() => systemPrompt));
+ let loadedTask = $state(untrack(() => taskPrompt));
+
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let varsLoading = $state(false);
+ let varsError = $state<string | null>(null);
+ let defaultLoading = $state(false);
+
+ let saving = $state(false);
+ let saveError = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ // The textarea currently focused — variable insertion targets THIS one.
+ type Field = "system" | "task";
+ let activeField = $state<Field>("system");
+ let systemEl = $state<HTMLTextAreaElement | null>(null);
+ let taskEl = $state<HTMLTextAreaElement | null>(null);
+
+ const groups = $derived(groupVariables(variables));
+ /** The baseline system text to diff against: the effective prompt at open +
+ * after the last save (override, or the default when inheriting) — so a
+ * pre-filled default does NOT register as an unsaved change, and a saved
+ * edit clears `hasChanges` (the baseline tracks the persisted value). */
+ const systemBaseline = $derived(effectiveSystemPrompt(loadedSystemRaw, defaultPrompt));
+ const hasChanges = $derived(system !== systemBaseline || task !== loadedTask);
+ /** Whether the current text matches the default (i.e. saving would inherit). */
+ const inheriting = $derived(system === defaultPrompt && defaultPrompt !== "");
+
+ async function loadVars(): Promise<void> {
+ untrack(() => {
+ varsLoading = true;
+ varsError = null;
+ });
+ const result = await loadVariables();
+ varsLoading = false;
+ if (result.ok) {
+ variables = result.variables;
+ } else {
+ varsError = result.error;
+ }
+ }
+
+ async function loadDefault(): Promise<void> {
+ untrack(() => {
+ defaultLoading = true;
+ });
+ const result = await loadDefaultPrompt();
+ defaultLoading = false;
+ if (result.ok) {
+ defaultPrompt = result.template;
+ // Pre-fill an inheriting (empty) override with the global default so the
+ // user can see + tweak what will run — but ONLY if they haven't edited
+ // the system field yet (system still equals the open-time raw override).
+ // Done here (not in a reactive $effect) so a late-loading default can't
+ // clobber an in-flight edit.
+ if (isInheritingSystemPrompt(loadedSystemRaw) && system === loadedSystemRaw) {
+ system = defaultPrompt;
+ }
+ }
+ // A failed default load is non-fatal: the editor still works with the
+ // raw override; only the "inherit" affordance is unavailable.
+ }
+
+ async function save(): Promise<void> {
+ if (saving || !hasChanges) return;
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ // Persist the system prompt via the inheritance helper: matching the
+ // default (or empty) → "" (inherit); otherwise the override verbatim.
+ const systemToPersist = persistedSystemPrompt(system, defaultPrompt);
+ const result = await saveConfig({ systemPrompt: systemToPersist, taskPrompt: task });
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ // Advance the diff baseline to the persisted value so `hasChanges`
+ // clears (systemBaseline recomputes off loadedSystemRaw). Without this
+ // the baseline stays pinned to the open-time value and the Save button
+ // never settles ("flickers and reverts to unsaved").
+ loadedSystemRaw = systemToPersist;
+ loadedTask = task;
+ justSaved = true;
+ onSaved(systemToPersist, task);
+ } else {
+ saveError = result.error;
+ }
+ }
+
+ /** Revert ALL edits to the open-time state (system effective prompt + task). */
+ function reset(): void {
+ system = systemBaseline;
+ task = loadedTask;
+ saveError = null;
+ justSaved = false;
+ }
+
+ /** Reset ONLY the system prompt to the global default (clears any override →
+ * inherit on save). No-op until the default has loaded. */
+ function resetSystemToDefault(): void {
+ if (defaultPrompt === "") return;
+ system = defaultPrompt;
+ saveError = null;
+ justSaved = false;
+ }
+
+ /**
+ * Insert a variable tag into the ACTIVE textarea at its cursor. The active
+ * field is tracked via focus handlers; insertion uses that field's element +
+ * its own text (so a tag never lands in the wrong box).
+ */
+ async function insertAtActive(tag: string): Promise<void> {
+ const el = activeField === "system" ? systemEl : taskEl;
+ if (el === null) return;
+ const start = el.selectionStart;
+ const end = el.selectionEnd;
+ if (activeField === "system") {
+ const ins = insertTag(system, tag, start, end);
+ system = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ } else {
+ const ins = insertTag(task, tag, start, end);
+ task = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ }
+ }
+
+ /** Dynamic (file:<path>) variable: build the tag from the input + insert. */
+ async function insertDynamic(type: string, path: string): Promise<void> {
+ const trimmed = path.trim();
+ if (trimmed.length === 0) return;
+ await insertAtActive(buildTag(type, trimmed));
+ }
+
+ function onKeydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load the variable palette + the global default once on open.
+ $effect(() => {
+ void loadVars();
+ void loadDefault();
+ });
+</script>
+
+<svelte:window onkeydown={onKeydown} />
+
+<!-- Teleported to <body> (use:portal) so `position: fixed` resolves against the
+ VIEWPORT, not the sidebar's `transform: translateX(...)` container — an
+ ancestor transform establishes a containing block for `fixed`, which would
+ otherwise clip this overlay to the sidebar area. (RunModal/SystemPromptBuilder
+ avoid this by rendering at the composition root; this modal lives inside
+ HeartbeatView, so it must escape its ancestor.) -->
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div
+ use:portal
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="Heartbeat prompt editor"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
+>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">Heartbeat Prompts</h2>
+ {#if varsLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close prompt editor"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor (two boxes) / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- Left: two text editors (system top, task bottom) -->
+ <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4">
+ <div class="flex min-h-0 flex-1 flex-col gap-1">
+ <div class="flex shrink-0 items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <span class="text-xs font-semibold uppercase opacity-60">System prompt</span>
+ {#if defaultLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else if inheriting}
+ <span class="badge badge-ghost badge-sm font-normal">Inheriting workspace default</span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-xs"
+ disabled={defaultPrompt === "" || saving}
+ onclick={resetSystemToDefault}
+ title="Reset the system prompt to the workspace default (inherit)"
+ >
+ Reset to default
+ </button>
+ </div>
+ <textarea
+ bind:this={systemEl}
+ bind:value={system}
+ onfocus={() => (activeField = "system")}
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder={defaultPrompt || "You are an autonomous agent…"}
+ disabled={saving}
+ aria-label="Heartbeat system prompt"
+ ></textarea>
+ <p class="shrink-0 text-xs opacity-50">
+ {#if inheriting}
+ Matches the workspace default — saving will inherit it (no override).
+ {:else if defaultPrompt !== ""}
+ Editing overrides the workspace default.
+ {:else}
+ Empty — no system prompt set.
+ {/if}
+ </p>
+ </div>
+
+ <div class="flex min-h-0 flex-1 flex-col gap-1">
+ <span class="shrink-0 text-xs font-semibold uppercase opacity-60">Task prompt</span>
+ <textarea
+ bind:this={taskEl}
+ bind:value={task}
+ onfocus={() => (activeField = "task")}
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder="Check the system status and report…"
+ disabled={saving}
+ aria-label="Heartbeat task prompt"
+ ></textarea>
+ </div>
+
+ <div class="flex shrink-0 flex-wrap items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={!hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if saveError}
+ <p class="shrink-0 text-xs text-error">{saveError}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ <p class="mb-3 shrink-0 text-xs opacity-50">
+ Click a variable to insert it into the focused prompt box.
+ </p>
+ {#if varsError}
+ <p class="text-xs text-error">{varsError}</p>
+ {:else if groups.length === 0 && !varsLoading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {:else}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <!-- Dynamic (file:<path>) variable: a path input + Insert button. -->
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ const v = e.currentTarget.value;
+ void insertDynamic(variable.type, v);
+ e.currentTarget.value = "";
+ }
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={(e) => {
+ const input = (e.currentTarget as HTMLButtonElement)
+ .previousElementSibling as HTMLInputElement | null;
+ if (input !== null) {
+ void insertDynamic(variable.type, input.value);
+ input.value = "";
+ }
+ }}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() =>
+ void insertAtActive(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
new file mode 100644
index 0000000..c4bd3b8
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -0,0 +1,168 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ SaveHeartbeatConfig,
+} from "../logic/types";
+import PromptEditor from "./PromptEditor.svelte";
+
+// Fakes for the injected ports.
+
+function fakeLoadVariables() {
+ return vi.fn(async () => ({ ok: true, variables: [] }) as const);
+}
+
+function fakeLoadDefaultPrompt(template = "You are a helpful assistant.") {
+ return vi.fn(async () => ({ ok: true, template }) as const);
+}
+
+/** A capturing saveConfig that resolves ok, echoing the merged config shape. */
+function fakeSaveConfig(): {
+ calls: HeartbeatConfigPatch[];
+ impl: SaveHeartbeatConfig;
+} {
+ const calls: HeartbeatConfigPatch[] = [];
+ const impl: SaveHeartbeatConfig = async (patch) => {
+ calls.push(patch);
+ // 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,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ };
+ return { ok: true, config } satisfies HeartbeatConfigResult;
+ };
+ return { calls, impl };
+}
+
+const baseProps = (overrides: Record<string, unknown> = {}) => ({
+ systemPrompt: "",
+ taskPrompt: "",
+ loadVariables: fakeLoadVariables(),
+ loadDefaultPrompt: fakeLoadDefaultPrompt(),
+ saveConfig: fakeSaveConfig().impl,
+ onSaved: vi.fn(),
+ onClose: vi.fn(),
+ ...overrides,
+});
+
+describe("PromptEditor save flow", () => {
+ it("persists an edited system prompt and clears the unsaved state (regression: save flickered + reverted)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ const onSaved = vi.fn();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start inheriting (empty override); the default pre-fills.
+ systemPrompt: "",
+ saveConfig: save.impl,
+ onSaved,
+ }),
+ });
+
+ // Wait for the default to load + pre-fill the system textarea.
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ // Save is disabled while it matches the default (no explicit edit).
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+
+ // Edit the system prompt → an override.
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom override");
+
+ // Save is now enabled.
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ // The save port was called with the override persisted verbatim.
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe("custom override");
+ expect(onSaved).toHaveBeenCalledWith("custom override", "");
+
+ // THE REGRESSION: after save, hasChanges must clear (Save disabled again)
+ // and the "Saved." confirmation shows — NOT "Unsaved changes".
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ expect(screen.queryByText(/Unsaved changes/i)).not.toBeInTheDocument();
+ });
+
+ it("persisting text that matches the default sends '' (inherit) and clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({
+ // Start with an override.
+ systemPrompt: "old override",
+ saveConfig: save.impl,
+ }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ expect(systemBox).toHaveValue("old override");
+
+ // Reset to default → text matches the default → saving inherits ("").
+ await user.click(screen.getByRole("button", { name: "Reset to default" }));
+ expect(systemBox).toHaveValue("You are a helpful assistant.");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls).toHaveLength(1);
+ expect(save.calls[0]?.systemPrompt).toBe(""); // inherit
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("editing the task prompt saves + clears unsaved state", async () => {
+ const user = userEvent.setup();
+ const save = fakeSaveConfig();
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: save.impl }),
+ });
+
+ const taskBox = await screen.findByLabelText("Heartbeat task prompt");
+ await user.type(taskBox, "do the thing");
+
+ const saveBtn = screen.getByRole("button", { name: "Save" });
+ expect(saveBtn).toBeEnabled();
+ await user.click(saveBtn);
+
+ expect(save.calls[0]?.taskPrompt).toBe("do the thing");
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
+ });
+ expect(screen.getByText("Saved.")).toBeInTheDocument();
+ });
+
+ it("a failed save surfaces the error and keeps the edit unsaved", async () => {
+ const user = userEvent.setup();
+ const failingSave: SaveHeartbeatConfig = async () => ({ ok: false, error: "boom" });
+ render(PromptEditor, {
+ props: baseProps({ saveConfig: failingSave }),
+ });
+
+ const systemBox = await screen.findByLabelText("Heartbeat system prompt");
+ await user.clear(systemBox);
+ await user.type(systemBox, "custom");
+
+ await user.click(screen.getByRole("button", { name: "Save" }));
+
+ expect(screen.getByText("boom")).toBeInTheDocument();
+ // Still unsaved (Save stays enabled), no success badge.
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
+ expect(screen.queryByText("Saved.")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/features/heartbeat/ui/RunModal.svelte b/src/features/heartbeat/ui/RunModal.svelte
new file mode 100644
index 0000000..92068ae
--- /dev/null
+++ b/src/features/heartbeat/ui/RunModal.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import { tick } from "svelte";
+ import { ChatView } from "../../chat";
+ import type { ChatStore } from "../../chat";
+ import type { HeartbeatRunView } from "../logic/view-model";
+ import type { StopHeartbeatRun } from "../logic/types";
+
+ let {
+ run,
+ openChat,
+ closeChat,
+ stopRun,
+ onClose,
+ apiBaseUrl = "",
+ }: {
+ /** The run to display (its conversation's chat is shown live). */
+ run: HeartbeatRunView;
+ /**
+ * Open a live watch on a conversation (the store's `watchConversation`):
+ * returns a {@link ChatStore} subscribed to the conversation's turn stream
+ * + history loaded. The modal owns the watch lifecycle — calls
+ * `closeChat` on unmount.
+ */
+ openChat: (conversationId: string) => ChatStore;
+ /** Dispose + unsubscribe the watch opened by `openChat`. */
+ closeChat: (conversationId: string) => void;
+ /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */
+ stopRun: StopHeartbeatRun;
+ onClose: () => void;
+ /**
+ * The HTTP API base URL, to resolve persisted image chunk URLs
+ * (`/images/…`) in the run's transcript. Defaults to "" (root-relative).
+ */
+ apiBaseUrl?: string;
+ } = $props();
+
+ // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run
+ // switch remounts it). `untrack` avoids re-running if the prop fn identity
+ // changes — `run.conversationId` is the real dependency, captured once here.
+ let chat = $state<ChatStore | null>(null);
+ $effect(() => {
+ chat = openChat(run.conversationId);
+ return () => closeChat(run.conversationId);
+ });
+
+ // Live scroll: keep the transcript pinned to the bottom while it streams
+ // (unless the reader has scrolled up — then we don't fight them).
+ let scrollEl = $state<HTMLDivElement | undefined>();
+ let contentEl = $state<HTMLDivElement | undefined>();
+ let pinned = $state(true);
+
+ function onScroll() {
+ const el = scrollEl;
+ if (el === undefined) return;
+ pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
+ }
+
+ // Follow the bottom on new content while pinned. Reads `chunks.length` so the
+ // effect re-runs on every streamed append.
+ const chunkCount = $derived(chat?.chunks.length ?? 0);
+ $effect(() => {
+ void chunkCount;
+ if (!pinned) return;
+ void tick().then(() => {
+ const el = scrollEl;
+ if (el !== undefined) el.scrollTop = el.scrollHeight;
+ });
+ });
+
+ // Stop state.
+ let stopping = $state(false);
+ let stopError = $state<string | null>(null);
+
+ async function handleStop() {
+ if (stopping) return;
+ stopping = true;
+ stopError = null;
+ const result = await stopRun(run.id);
+ stopping = false;
+ if (result === null) return;
+ if (!result.ok) stopError = result.error;
+ }
+
+ // The live "running" signal: the chat store's `generating` reflects the
+ // actual event stream (turn-start…turn-sealed). True while a turn streams —
+ // that is when a Stop is meaningful. Falls back to the run's status snapshot
+ // before the stream attaches.
+ const live = $derived(chat?.generating ?? run.busy);
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === "Escape") onClose();
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+<!-- Fullscreen overlay. -->
+<div class="fixed inset-0 z-50 flex flex-col bg-base-100">
+ <!-- Header -->
+ <header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2">
+ <div class="flex min-w-0 items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ onclick={onClose}
+ aria-label="Close run chat"
+ >
+ ✕
+ </button>
+ <span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span>
+ {#if live}
+ <span class="badge badge-sm badge-warning gap-1">
+ <span class="loading loading-spinner loading-xs"></span>
+ Running
+ </span>
+ {:else}
+ <span class="badge badge-sm badge-ghost">{run.statusLabel}</span>
+ {/if}
+ </div>
+ <div class="flex items-center gap-2">
+ {#if stopError}
+ <span class="text-xs text-error">{stopError}</span>
+ {/if}
+ {#if live}
+ <button
+ type="button"
+ class="btn btn-sm btn-error btn-outline"
+ disabled={stopping}
+ onclick={handleStop}
+ >
+ {#if stopping}
+ <span class="loading loading-spinner loading-xs"></span>
+ Stopping…
+ {:else}
+ Stop
+ {/if}
+ </button>
+ {/if}
+ </div>
+ </header>
+
+ <!-- Transcript -->
+ <div class="relative min-h-0 flex-1">
+ <div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}>
+ <div bind:this={contentEl} class="p-4">
+ {#if chat === null}
+ <div class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-md"></span>
+ </div>
+ {:else if chat.chunks.length === 0 && chat.pendingSync}
+ <div class="flex h-full items-center justify-center">
+ <span class="loading loading-spinner loading-md"></span>
+ </div>
+ {:else}
+ <ChatView
+ chunks={chat.chunks}
+ turnMetrics={chat.turnMetrics}
+ hasEarlier={chat.hasEarlier}
+ onShowEarlier={chat.showEarlier}
+ thinkingKeyBase={chat.thinkingKeyBase}
+ providerRetry={chat.providerRetry}
+ apiBaseUrl={apiBaseUrl}
+ />
+ {/if}
+ </div>
+ </div>
+ {#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-2xl font-bold opacity-10">No messages</span>
+ </div>
+ {/if}
+ </div>
+</div>