summaryrefslogtreecommitdiffhomepage
path: root/src/features/heartbeat/ui/HeartbeatView.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/heartbeat/ui/HeartbeatView.svelte')
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte536
1 files changed, 536 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..5f262f8
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -0,0 +1,536 @@
+<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 };
+ }
+ }
+
+ // ── 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}
+ <!-- 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}