summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md280
-rw-r--r--src/adapters/portal.test.ts49
-rw-r--r--src/adapters/portal.ts28
-rw-r--r--src/app/App.svelte1280
-rw-r--r--src/app/store.svelte.ts252
-rw-r--r--src/app/store.test.ts251
-rw-r--r--src/features/heartbeat/index.ts50
-rw-r--r--src/features/heartbeat/logic/types.ts103
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts470
-rw-r--r--src/features/heartbeat/logic/view-model.ts410
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte536
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte412
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts167
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte169
-rw-r--r--src/features/system-prompt/index.ts2
15 files changed, 3852 insertions, 607 deletions
diff --git a/backend-handoff.md b/backend-handoff.md
index 1321c3e..aa61fc1 100644
--- a/backend-handoff.md
+++ b/backend-handoff.md
@@ -5,11 +5,10 @@
> **From:** dispatch-web orchestrator · **To:** `../backend` orchestrator · **Courier:** the user.
> `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here.
-_Last updated: 2026-06-25 (§2d RESOLVED — backend merged `dev` into `feature/ssh-support`, merge `de022ce`;
-`TurnProviderRetryEvent`/`provider-retry` is now present alongside the SSH types; FE re-synced both `file:` deps +
-`bun run typecheck` is GREEN — 0 errors — with ZERO further FE code changes, exactly as predicted. The full SSH
-computer feature (handoff #2, §2e) + the wire-type break (handoff #1) are unchanged; the merge only added
-`provider-retry` on top. FE is now fully green: typecheck 0/0, 795/795 tests, biome clean, build OK)._
+_Last updated: 2026-06-26 (§2i ADDED — Heartbeat next-run countdown timer: FE shows a live "Next run in Xm Ys" countdown
+from a 1s clock; opens 1 backend ask CR-HB-3: new `GET /workspaces/:id/heartbeat/next-run` → `{ nextRunAt: ISO|null }`.
+FE falls back to an approximation (latest run + interval) until the endpoint ships. typecheck 0/0, 865 tests green, biome
+clean, build OK. §2h/§2g/§2f unchanged.)_
**FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9**
(`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence
(§2d) is RESOLVED.
@@ -446,6 +445,277 @@ human confirm of the dropdown/badge/test should run once `ssh` is wired + the `p
---
+## 2f. Heartbeat (workspace autonomous-agent loop) → **CONSUMED ✅ (backend shipped; FE built)**
+
+The backend shipped a workspace-scoped **heartbeat** — an autonomous agent loop that periodically runs a turn in a
+dedicated conversation using a configured system prompt, task prompt, model, reasoning effort, and interval. The FE
+exposes the config, run history, and a per-run live chat in a new sidebar **Heartbeat** view (branch `feature/heartbeat`).
+
+**Backend API (plain REST — NOT a transport-contract type):**
+- `GET /workspaces/:id/heartbeat` → `{ enabled, systemPrompt, taskPrompt, intervalMinutes, model, reasoningEffort }`
+- `PUT /workspaces/:id/heartbeat` (partial body) → updated config
+- `GET /workspaces/:id/heartbeat/runs` → `{ runs: [{ id, conversationId, triggeredAt, status }] }` (`status: running|completed|stopped`)
+- `POST /workspaces/:id/heartbeat/runs/:runId/stop` → `{ ok: true }`
+
+**Contract note (important):** the heartbeat shapes are NOT in `@dispatch/transport-contract` / `@dispatch/wire`
+(verified: no `heartbeat` symbol in either `dist/`). So the FE owns the types locally in `src/features/heartbeat/logic/types.ts`
+(consumer-defines-port, mirroring the `mcp`/`computer` result-type pattern) and coerces the untyped JSON at the network
+seam via pure `normalizeHeartbeatConfig`/`normalizeHeartbeatRuns` (a malformed/partial response can never crash the
+renderer). **If the backend later promotes these to a shared contract package, swap the local types for the imports +
+re-mirror `.dispatch/*.reference.md`.** No contract version bump on the FE side (no `file:` dep re-pin needed).
+
+**FE (DONE + verified):**
+- New feature library `src/features/heartbeat/`: pure `logic/view-model.ts` (`viewRun`/`viewRuns`/`badgeForStatus`/
+ `statusLabelFor`/`formatRunTime`/`relativeLabel`/config-form helpers `formFromConfig`/`patchFromForm`/`formDiffers`/
+ `normalizeInterval` (1–1440 clamp)/network normalizers + `effortOptions` re-exported from `features/chat`) — 35
+ view-model tests green; `ui/HeartbeatView.svelte` (config panel: enable toggle (saves immediately), system + task
+ prompt textareas, model dropdown, reasoning-effort dropdown, interval input, Save button with `hasChanges` guard;
+ scrolling runs list polling every 4s with a spinner when running + per-row Stop) + `ui/RunModal.svelte` (fullscreen
+ modal that reuses `features/chat`'s `ChatView` to render the run's conversation; live-streams via the store's
+ `watchConversation`/`chat.subscribe` while `generating`, with a Stop button → `POST .../runs/:runId/stop`);
+ `index.ts` (`HeartbeatView`/`RunModal`/`manifest`/types). The reasoning-effort ladder is REUSED from `features/chat`
+ (sanctioned cross-feature import through its public exports) — no drift.
+- `AppStore` (`src/app/store.svelte.ts`): `heartbeatConfig`/`setHeartbeatConfig`/`heartbeatRuns`/`stopHeartbeatRun`
+ (workspace-scoped; read `activeWorkspaceId` with `untrack`) + **`watchConversation`/`unwatchConversation`** — a new
+ "watch" mechanism for the modal: reuses an open tab's `ChatStore` (already subscribed) or creates an EPHEMERAL watch
+ store in a new `watchStores` map (separate from tabs — never opens a tab) + subscribes via `chat.subscribe` + loads
+ history; deltas route to it (the `handleChatMessage` hot path now also checks `watchStores`); `onReopen` re-subscribes
+ + resyncs watch stores; `dispose` disposes them. `unwatchConversation` is a no-op for a tab conversation (it keeps its
+ store + stream) — only the ephemeral watch store is disposed + unsubscribed.
+- Wired into `src/app/App.svelte`: `"heartbeat"` view kind (in `viewKinds`), `heartbeatManifest` in `loadedModules`,
+ thin adapter functions, the `viewContent` snippet branch, + `{#key heartbeatRun.id}` RunModal render when a run is
+ selected.
+- Store tests (`src/app/store.test.ts`): +7 — config load/PUT-merge/error, runs load, stop POST, watch subscribe +
+ live-delta routing, tab-reuse (no extra subscribe) + unwatch no-op for a tab.
+- Also fixed a PRE-EXISTING `WorkspaceCard.test.ts` typo (`onNavigate` shorthand used before declaration →
+ `onNavigate: vi.fn()`) that was red on the branch HEAD independent of heartbeat.
+
+**Verification:** 837/837 tests green (run TWICE — no cross-test pollution; the watch stores are plain `Map`s, not
+shared globals, but the methodology's double-run is honored); `svelte-check` 0 errors; biome clean; `vite build`
+succeeds. Live probe NOT run (the backend's heartbeat loop + endpoints were not reachable headless at verify time; the
+unit + store tests fully cover the data path + the WS routing seam). To confirm end-to-end: start the backend, open the
+Heartbeat sidebar view, toggle enable, edit+Save the config, watch a run appear + stream live in the modal, click Stop.
+
+**Vocabulary note (heads-up, not blocking):** `GLOSSARY.md` marks **"view"** as RESERVED (old-Dispatch sidebar
+affordance, future). The heartbeat UI follows the codebase's ESTABLISHED convention — sidebar panels are already called
+"views" pervasively (`viewKinds`, `ViewSidebar`, `viewContent`, "Model view"/"LSP view"/"Settings view"). The feature
+module itself is named `heartbeat` (a feature module). No new term was coined. If the reserved-"view" cleanup happens
+later, the heartbeat view kind renames in lockstep with the rest.
+
+---
+
+## 2g. Heartbeat follow-up UI — prompt editor modal + hours/minutes timer → **FE BUILT; 1 BACKEND ASK**
+
+A follow-up to §2f (branch `feature/heartbeat`, uncommitted-to-this-handoff commit). Two UI changes on the heartbeat
+config panel, each analyzed below for backend impact. **One needs a backend change (variable resolution in the
+heartbeat prompts); the other needs none.**
+
+### Change A — Prompt editor modal (replaces the two textareas) → **1 BACKEND ASK (CR-HB-1)**
+
+The config panel's two inline textareas (system prompt + task prompt) are replaced by a single "Edit prompts" button
+that opens a full-width modal (`src/features/heartbeat/ui/PromptEditor.svelte`):
+- LEFT side: two stacked text editors (top = system prompt, bottom = task prompt).
+- RIGHT side: the variable palette (grouped by type, same `[type:name]` tag insertion as the global system-prompt
+ builder). Clicking a variable inserts `[type:name]` at the cursor of whichever textarea is focused.
+- Save persists both prompts via the existing `PUT /workspaces/:id/heartbeat` with `{ systemPrompt, taskPrompt }`
+ (a partial patch — no new endpoint, no data-shape change).
+
+**What the FE reuses (NO new endpoint needed):**
+- The variable palette is sourced from the EXISTING global `GET /system-prompt/variables` endpoint — the SAME one the
+ global System Prompt builder uses. The heartbeat feature imports the pure helpers (`buildTag`, `groupVariables`,
+ `insertTag`, `isDynamicVariable`) from `features/system-prompt` (its public `index.ts` — `isDynamicVariable` was added
+ to that export in this slice, an additive cross-unit seam change). So there is **no new variable source or endpoint**;
+ the heartbeat prompt editor offers the exact same variable tags the global system prompt does.
+- The persisted strings carry literal `[type:name]` placeholders (plain text), exactly like the global template.
+
+**CR-HB-1 — Resolve `[type:name]` variables in the heartbeat system/task prompts → ASK (needs backend confirmation + likely implementation):**
+
+The global system-prompt template is resolved by the backend: `[type:name]` placeholders (e.g. `[system:os]`,
+`[system:date]`, `[file:path]`, `[if system:wsl]…`) are substituted with their resolved values at construction time
+(once per conversation at first turn, then persisted for prompt-cache safety). **The critical question: does the
+backend apply this SAME variable resolution to the heartbeat's `systemPrompt` and `taskPrompt` fields?**
+
+- If YES (the heartbeat prompts already flow through the same resolver as the global template): **no backend change
+ needed** — the FE just inserts the same `[type:name]` tags and the backend resolves them. Confirm + close.
+- If NO (the heartbeat prompts are inserted into the model turn as raw text, so `[system:os]` would reach the model
+ literally as the string `[system:os]` rather than the resolved OS string): **the backend should resolve `[type:name]`
+ placeholders in the heartbeat `systemPrompt` + `taskPrompt` using the SAME resolver/variable set as the global
+ system prompt** (so a variable inserted in either place resolves identically). This is the expected gap — the heartbeat
+ prompts are a NEW surface that predates the variable system, so they likely bypass the resolver.
+
+ - Resolution timing: mirror the global template's behavior — resolve once when a heartbeat run's turn is constructed
+ (NOT on every interval tick, to stay prompt-cache-safe; a stable resolved prompt keeps the cache warm across runs).
+ If the heartbeat re-resolves per-run anyway (intervals may want fresh `[system:time]`), that's a backend product
+ decision — the FE doesn't care WHEN it resolves, only THAT `[type:name]` becomes its value.
+ - Variable set: the SAME catalog `GET /system-prompt/variables` returns (system/file/prompt/git groups). No
+ heartbeat-specific variables are required for this slice.
+ - No wire/transport-contract/ui-contract change needed — this is a backend behavior change (the prompt strings are
+ still `string`; the FE is unaffected once the backend resolves them).
+
+**Optional future enhancement (NOT required now):** heartbeat-specific variables (e.g. `[heartbeat:runCount]`,
+`[heartbeat:lastResult]`, `[heartbeat:elapsed]`). The FE's prompt editor would offer these if `GET /system-prompt/variables`
+(or a new `GET /workspaces/:id/heartbeat/variables`) returned them. Defer until there's a product need.
+
+### Change B — Hours + minutes interval timer → **NO backend change needed**
+
+The interval input is split into two fields: an HOURS input (left) + a MINUTES input (right, 0–59). The conversion is
+entirely FE-side:
+- On load: the backend's single `intervalMinutes` is split into `{ hours, minutes }` via the pure `splitInterval`
+ helper (`Math.floor(total/60)` hours, remainder minutes).
+- On save: the FE recombines via `joinInterval(hours, minutes)` → `hours*60 + minutes` (clamped to the 1–1440 range
+ = 1 min–24 h) and sends a SINGLE `intervalMinutes` integer to `PUT /workspaces/:id/heartbeat`.
+
+So **the backend's `intervalMinutes` field is UNCHANGED** — still one integer of total minutes. The hours/minutes split
+is pure FE presentation. No endpoint, data-shape, or behavior change. (The existing clamp to 1–1440 also stands; a
+0h0m entry clamps to 1 minute — the FE guards this, and the backend's own validation should too, but that's pre-existing.)
+
+### FE summary (this slice)
+- `src/features/heartbeat/logic/view-model.ts`: `HeartbeatFormState` now carries `intervalHours` + `intervalMinutes`
+ (0–59); new pure `splitInterval`/`joinInterval` round-trip helpers; `formFromConfig`/`patchFromForm`/`formDiffers`
+ updated to split/recombine. +4 tests (split/join round-trip, form split, differs-after-edit).
+- `src/features/heartbeat/ui/PromptEditor.svelte` (new): two-pane modal (system+task editors left, variable palette
+ right), focus-aware variable insertion, Save/Reset. Reuses `features/system-prompt`'s pure helpers.
+- `src/features/heartbeat/ui/HeartbeatView.svelte`: two textareas → "Edit prompts" button + modal; interval → hours+minutes inputs.
+- `src/features/system-prompt/index.ts`: added `isDynamicVariable` to the public exports (additive — needed by the
+ heartbeat editor's dynamic `file:<path>` row).
+- `src/app/App.svelte`: passes `loadVariables={loadSystemPromptVariablesPrompt}` to `HeartbeatView`.
+
+**Verification:** typecheck 0/0, tests green, biome clean, build OK (see the commit). The variable-resolution behavior
+(CR-HB-1) can only be confirmed end-to-end against a running backend with variable-laden heartbeat prompts — a human
+should set `[system:os]` in a heartbeat prompt via the new editor, trigger a run, and confirm the resolved value (not the
+literal `[system:os]`) appears in the model's context / run transcript.
+
+---
+
+## 2h. Heartbeat system-prompt default + reset button → **FE BUILT; 1 BACKEND ASK (CR-HB-2)**
+
+A follow-up to §2f/§2g (branch `feature/heartbeat`). Two UX changes on the heartbeat prompt editor:
+1. The system prompt now **defaults to the workspace's regular system prompt** (the global `GET /system-prompt`
+ template — there is no per-workspace system prompt; `Workspace` has no `systemPrompt` field, and the system prompt
+ is global, resolved once per conversation). When the heartbeat's `systemPrompt` is empty, the editor pre-fills the
+ textarea with the global default so the user can see + tweak what will run — but a pre-filled default is NOT an
+ explicit edit (no `hasChanges` until the user edits away from it).
+2. A **"Reset to default" button** reverts the system prompt to the global default (clearing any override → inherit).
+
+### Semantics: heartbeat `systemPrompt` is an OVERRIDE; empty = inherit the global default
+
+Following the codebase's established resolution-chain pattern (cwd / reasoning-effort / model / computer — all
+"persisted value OR fall back to a default; resolution is SERVER-owned"), the heartbeat's `systemPrompt` is now treated
+as an **override**:
+- `systemPrompt === ""` (empty) → **inherit** the global system prompt (the workspace's regular prompt).
+- non-empty → an explicit override.
+
+The FE never duplicates the global default into the heartbeat config: when the editor's text matches the default (or is
+empty), it persists `systemPrompt: ""` (inherit) — so a later change to the global default still flows through. A
+distinct edit persists the override verbatim. Pure helpers in `src/features/heartbeat/logic/view-model.ts`:
+`effectiveSystemPrompt(override, default)`, `isInheritingSystemPrompt(override)`,
+`persistedSystemPrompt(editable, default)` (+6 tests).
+
+### CR-HB-2 — Resolve an empty heartbeat `systemPrompt` to the global system prompt at run time → **ASK (backend behavior change)**
+
+The FE sends `systemPrompt: ""` to inherit. **The backend must resolve an empty heartbeat `systemPrompt` to the GLOBAL
+system prompt template (`GET /system-prompt`) at heartbeat-run construction time** — so a heartbeat with no override
+actually runs the workspace's regular system prompt (not an empty one).
+
+- **Resolution:** when building a heartbeat run's turn, if the heartbeat's persisted `systemPrompt` is `""`, substitute
+ the global system prompt template (the same one `GET /system-prompt` returns / that conversations resolve). If
+ non-empty, use the override as-is.
+- **Composes with CR-HB-1:** after resolving empty → global (CR-HB-2), the resulting prompt's `[type:name]` variable
+ placeholders must still be resolved (CR-HB-1). So both apply, in order: (1) empty ⇒ global template, (2) resolve
+ `[type:name]` placeholders in whichever prompt is in effect. (For an override, only step 2 applies.)
+- **Timing / cache-safety:** mirror the global template's behavior — resolve once per heartbeat run (or once + persist,
+ whichever the heartbeat scheduler already does for prompt-cache safety). The FE doesn't care WHEN; only THAT empty
+ becomes the global prompt.
+- **No wire/transport-contract/ui-contract change** — `systemPrompt` is still a `string`; empty = inherit. The FE is
+ unaffected once the backend resolves it. **No new endpoint needed** — the FE already reads the global default via the
+ existing `GET /system-prompt` (passed through as `loadDefaultPrompt`).
+
+### FE summary (this slice)
+- `src/features/heartbeat/logic/view-model.ts`: 3 pure inheritance helpers (+6 tests).
+- `src/features/heartbeat/ui/PromptEditor.svelte`: `loadDefaultPrompt` port (the global `GET /system-prompt`); loads
+ the default on open + pre-fills the system textarea when inheriting; `hasChanges` diffs against the EFFECTIVE prompt
+ (override or default) so a pre-filled default isn't an unsaved change; save persists `""` when the text matches the
+ default (inherit); new "Reset to default" button + "Inheriting workspace default" badge + status hint.
+- `src/features/heartbeat/ui/HeartbeatView.svelte`: passes `loadDefaultPrompt` through; `onSaved` syncs the raw
+ override (`""` = inherit) into the form.
+- `src/app/App.svelte`: passes `loadDefaultPrompt={loadSystemPromptPrompt}` (reuses the existing `store.loadSystemPrompt()` adapter) to `HeartbeatView`.
+
+**Verification:** typecheck 0/0, 849 tests green, biome clean, build OK. The inheritance RUNTIME behavior (CR-HB-2) can
+only be confirmed against a running backend: set the heartbeat system prompt to empty (or click "Reset to default" +
+Save), trigger a run, and confirm the run used the GLOBAL system prompt (not an empty one). Until CR-HB-2 ships, an
+empty heartbeat `systemPrompt` would run with no system prompt — the FE flags this as the known gap.
+
+---
+
+## 2i. Heartbeat next-run countdown timer → **FE BUILT; 1 BACKEND ASK (CR-HB-3)**
+
+A follow-up to §2f/§2g/§2h (branch `feature/heartbeat`). The Heartbeat sidebar view now shows a live countdown to the
+next scheduled run ("Next run in 4m 32s") beneath the Enabled/Disabled status. The FE computes it from a server-
+authoritative next-run timestamp + a 1s ticking clock.
+
+### CR-HB-3 — `GET /workspaces/:id/heartbeat/next-run` → **ASK (new endpoint)**
+
+**New endpoint:**
+```
+GET /workspaces/:id/heartbeat/next-run
+```
+**Response shape:**
+```json
+{ "nextRunAt": "2026-06-25T14:05:00Z" }
+```
+or `null` when the heartbeat is **disabled** or **no run is scheduled**:
+```json
+{ "nextRunAt": null }
+```
+
+**Semantics:**
+- `nextRunAt` is the server-authoritative timestamp of the NEXT scheduled heartbeat run (the moment the scheduler will
+ fire it), as an ISO 8601 string. It is DERIVED server-side from the scheduler state (the last run's start + the
+ configured `intervalMinutes`, or the moment `enabled` was toggled on + `intervalMinutes` for the first run) — the FE
+ cannot derive it accurately (it doesn't know when the last run fired relative to "now" + scheduling jitter, paused-
+ while-running, etc.).
+- `null` when the heartbeat is disabled, or when no run is currently scheduled (e.g. a run is in flight and the next
+ hasn't been queued yet — the FE then shows no countdown, not a fabricated one).
+- Recompute on each call (a cheap read of the scheduler's next-fire time). The FE polls it on the same 4s cadence as
+ the runs list, so a run completing (→ next run scheduled) reflects within ~4s.
+
+**Why a dedicated endpoint (not a field on the config/runs response):** the user explicitly asked for "a timestamp
+endpoint." It's also the most efficient for polling (a lightweight read of just the next-fire time, vs. re-fetching the
+full config or runs). The FE polls it alongside the runs list every 4s.
+
+**No wire/transport-contract/ui-contract change** — the response is a plain JSON object (the heartbeat API is a plain
+REST surface, not a transport-contract type; the FE owns the `HeartbeatNextRunResult` type locally in
+`src/features/heartbeat/logic/types.ts` and coerces the untyped body at the network seam).
+
+### FE behavior (this slice) — works BEFORE the backend ships the endpoint
+
+- The store's `heartbeatNextRun()` calls `GET /workspaces/:id/heartbeat/next-run`; on **404/error** it returns
+ `ok: false` (non-fatal). The FE then sets a `nextRunEndpointFailed` flag and **stops polling the endpoint** (no 404
+ spam) and falls back to an APPROXIMATION: the latest run's `triggeredAt` + the configured `intervalMinutes`
+ (`approximateNextRunEpoch`, pure + tested). So the countdown shows (approximate) immediately, and becomes ACCURATE
+ once the backend ships CR-HB-3 (the FE prefers the server value when available).
+- A 1s ticking clock (`now` state) recomputes the countdown locally from `effectiveNextRun` (server value, else the
+ approximation) — no per-second network churn. Pure `formatCountdown(remainingMs)` → "4m 32s" / "32s" / "1h 05m" /
+ "due" (≤0) / "—" (unknown).
+- The countdown only renders when the heartbeat is **enabled** AND a next-run time is known (`effectiveNextRun !==
+ null`); disabled → no countdown (just "Disabled").
+- Edge: when the countdown reaches "due" (≤0), a run should be firing; the next 4s poll refreshes `nextRunAt` to the
+ newly-scheduled run. The approximation similarly refreshes when a new run appears in the runs list.
+
+### FE summary (this slice)
+- `src/features/heartbeat/logic/types.ts`: `HeartbeatNextRunResult` + `LoadHeartbeatNextRun` port.
+- `src/features/heartbeat/logic/view-model.ts`: pure `nextRunEpoch` (parse ISO), `formatCountdown`, `approximateNextRunEpoch` (+12 tests).
+- `src/app/store.svelte.ts`: `heartbeatNextRun()` (GET `.../heartbeat/next-run`; graceful 404 → `ok:false`).
+- `src/features/heartbeat/ui/HeartbeatView.svelte`: polls `nextRun` (stop-on-fail + fallback), 1s countdown clock, "Next run in …" under the status.
+- `src/app/App.svelte`: `loadHeartbeatNextRun` adapter → `HeartbeatView`.
+
+**Verification:** typecheck 0/0, 865 tests green (+12 next-run helpers), biome clean, build OK. The accurate countdown
+can only be confirmed against a running backend with CR-HB-3 shipped (enable the heartbeat, watch "Next run in …" tick
+down, confirm it matches when a run actually fires). Until CR-HB-3 ships, the FE shows the APPROXIMATE countdown
+(latest run + interval) — flagged as the known gap.
+
+---
+
## 3. Likely NEXT backend asks (heads-up, not yet requested)
- **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns
diff --git a/src/adapters/portal.test.ts b/src/adapters/portal.test.ts
new file mode 100644
index 0000000..a5624d5
--- /dev/null
+++ b/src/adapters/portal.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, describe, expect, it } from "vitest";
+import { portal } from "./portal";
+
+describe("portal action", () => {
+ afterEach(() => {
+ // Strip any leftover teleported nodes between tests.
+ document.querySelectorAll("body > :not(script)").forEach((n) => {
+ if (n instanceof HTMLElement) n.remove();
+ });
+ });
+
+ it("teleports the node to document.body (escaping an ancestor with transform)", () => {
+ // Simulate the sidebar: a transformed ancestor establishes a containing
+ // block for `position: fixed`.
+ const ancestor = document.createElement("div");
+ ancestor.style.transform = "translateX(0)";
+ document.body.appendChild(ancestor);
+
+ const node = document.createElement("div");
+ node.setAttribute("data-testid", "modal");
+ ancestor.appendChild(node);
+ expect(node.parentNode).toBe(ancestor);
+
+ const action = portal(node);
+
+ // After the action, the node is a direct child of <body>, not the ancestor.
+ expect(node.parentNode).toBe(document.body);
+ expect(ancestor.contains(node)).toBe(false);
+
+ action.destroy();
+
+ // On destroy the node is removed from <body>.
+ expect(document.body.contains(node)).toBe(false);
+ });
+
+ it("is a no-op (does not throw) when document is unavailable (SSR guard)", () => {
+ const originalDocument = globalThis.document;
+ // @ts-expect-error — deliberately undefined to exercise the SSR guard.
+ globalThis.document = undefined;
+ try {
+ const stub = {} as HTMLElement;
+ const action = portal(stub);
+ // Must not throw, and returns a destroy that is safe to call.
+ action.destroy();
+ } finally {
+ globalThis.document = originalDocument;
+ }
+ });
+});
diff --git a/src/adapters/portal.ts b/src/adapters/portal.ts
new file mode 100644
index 0000000..afe42e7
--- /dev/null
+++ b/src/adapters/portal.ts
@@ -0,0 +1,28 @@
+/**
+ * A Svelte `use:` action that teleports a node to `document.body`, escaping any
+ * ancestor that establishes a containing block for `position: fixed` (most
+ * commonly an ancestor with a `transform`, `filter`, `perspective`, or
+ * `will-change` — e.g. the sidebar's `transform: translateX(...)` container).
+ *
+ * Without this, a `position: fixed` modal rendered inside such an ancestor is
+ * positioned relative to the ANCESTOR, not the viewport (so it only covers the
+ * sidebar area instead of the full screen). Moving the node to `document.body`
+ * restores viewport-relative `fixed` positioning. Svelte still owns the node's
+ * lifecycle (children, bindings, events); we just relocate it + remove it on
+ * destroy as hygiene.
+ *
+ * No-op safely when there is no `document` (SSR / jsdom guards).
+ */
+export function portal(node: HTMLElement): { destroy(): void } {
+ if (typeof document === "undefined") {
+ return { destroy() {} };
+ }
+ document.body.appendChild(node);
+ return {
+ destroy() {
+ if (node.parentNode === document.body) {
+ document.body.removeChild(node);
+ }
+ },
+ };
+}
diff --git a/src/app/App.svelte b/src/app/App.svelte
index a3acaeb..09be947 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -1,614 +1,694 @@
<script lang="ts">
- import type { ReasoningEffort } from "@dispatch/transport-contract";
- import type { InvokeMessage } from "@dispatch/ui-contract";
- import { tick } from "svelte";
- import Table from "../components/Table.svelte";
- import {
- CacheWarmingView,
- manifest as cacheWarmingManifest,
- type WarmFeedback,
- } from "../features/cache-warming";
- import {
- ChatView,
- CompactionView,
- Composer,
- manifest as chatManifest,
- ModelSelector,
- ReasoningEffortSelector,
- type CompactNowResult,
- type ReasoningEffortSaveResult,
- type SaveCompactPercentResult,
- } from "../features/chat";
- import { manifest as conversationCacheManifest } from "../features/conversation-cache";
- import { manifest as markdownManifest } from "../features/markdown";
- import { McpStatusView, manifest as mcpManifest, type McpStatusResult } from "../features/mcp";
- import {
- ChatLimitField,
- manifest as settingsManifest,
- type ChatLimitSaveResult,
- } from "../features/settings";
- import {
- createSmartScrollController,
- manifest as smartScrollManifest,
- ScrollToBottom,
- } from "../features/smart-scroll";
- import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
- import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
- import { parseTodoPayload } from "../features/surface-host/logic/todo";
- import TodoList from "../features/surface-host/ui/TodoList.svelte";
- import { manifest as tabsManifest, TabBar } from "../features/tabs";
- import { manifest as viewsManifest, ViewSidebar } from "../features/views";
- import {
- CwdField,
- type CwdSaveResult,
- LspStatusView,
- type LspStatusResult,
- manifest as cwdLspManifest,
- } from "../features/cwd-lsp";
- import {
- ComputerField,
- manifest as computerManifest,
- type ComputerSaveResult,
- type ComputerStatusResult,
- type LoadComputerStatus,
- type SaveComputer,
- type TestComputer,
- type TestComputerResult,
- } from "../features/computer";
- import {
- SystemPromptBuilder,
- type LoadSystemPrompt as LoadSystemPromptAlias,
- type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
- type SaveSystemPrompt as SaveSystemPromptAlias,
- manifest as systemPromptManifest,
- } from "../features/system-prompt";
- import type { AppStore } from "./store.svelte";
- import ErrorModal from "./ErrorModal.svelte";
- import { createLocalStore } from "../adapters/local-storage";
- import { untrack } from "svelte";
-
- let { store }: { store: AppStore } = $props();
-
- // The backend's conversation-scoped cache-warming surface. Referenced by id at
- // the composition root (sanctioned discovery-by-id) to give it a dedicated view
- // and keep it out of the generic Extensions surface list — SurfaceView itself
- // stays fully generic (it never switches on a surface id).
- const CACHE_WARMING_ID = "cache-warming";
- // The message-queue extension's per-conversation surface (steering). Pulled
- // out of the generic Extensions list and rendered as a compact panel above the
- // composer — pending steering messages are tied to the chat, not the sidebar.
- const MESSAGE_QUEUE_ID = "message-queue";
- // The `todo` extension's per-conversation task list surface (model-maintained).
- const TODO_ID = "todo";
-
- // The view kinds offered in the sidebar's dropdown. Generic data — the
- // `viewContent` snippet below maps each kind id to its renderer.
- const viewKinds = [
- { id: "model", label: "Model" },
- { id: "lsp", label: "Language Servers" },
- { id: "mcp", label: "MCP Servers" },
- { id: "extensions", label: "Extensions" },
- { id: "cache-warming", label: "Cache Warming" },
- { id: "tasks", label: "Tasks" },
- { id: "compaction", label: "Compaction" },
- { id: "system-prompt", label: "System Prompt" },
- { id: "settings", label: "Settings" },
- ] as const;
-
- // Default sidebar layout: just the Model view.
- const DEFAULT_VIEWS: readonly string[] = ["model"];
- const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
- storage: untrack(() => store.storage),
- });
- const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
-
- function handleSidebarChange(kinds: readonly (string | null)[]): void {
- sidebarStore.save(kinds.filter((k): k is string => k !== null));
- }
-
- // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
- // feature's public `manifest` export so it can't drift from what's actually
- // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
- // backend-owned list.) FE features are internal units of this single repo, so
- // there is no per-module version — they all share dispatch-web's version.
- const MODULE_COLUMNS = ["Module", "Description"] as const;
- const loadedModules: readonly (readonly [string, string])[] = [
- chatManifest,
- tabsManifest,
- surfaceHostManifest,
- viewsManifest,
- conversationCacheManifest,
- markdownManifest,
- cacheWarmingManifest,
- cwdLspManifest,
- mcpManifest,
- computerManifest,
- smartScrollManifest,
- settingsManifest,
- systemPromptManifest,
- ].map((m) => [m.name, m.description] as const);
-
- // Smart-scroll: keep the transcript pinned to the bottom while it streams,
- // unless the reader has scrolled up (then show a "scroll to bottom" button).
- // One controller owns the chat scroll region; effects below feed it the edges.
- const smartScroll = createSmartScrollController();
- let transcriptEl = $state<HTMLElement | undefined>();
- let transcriptContentEl = $state<HTMLElement | undefined>();
-
- // Chat-limit unload gate: old chunks may be unloaded only while the reader is
- // stuck to the bottom. While stuck, a trim removes content far ABOVE the
- // viewport and the controller re-pins to the bottom — no visible jump; while
- // reading history, trimming is deferred instead of yanking the page (the old
- // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
- $effect(() => {
- store.attachUnloadGate(() => smartScroll.isAtBottom());
- });
-
- // "Show earlier messages": page older history back in, preserving the reader's
- // viewport position — prepended content grows scrollHeight, so shift scrollTop
- // by the growth (the manual analogue of CSS scroll anchoring, which not every
- // engine applies here).
- async function handleShowEarlier(): Promise<void> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- await store.activeChat.showEarlier();
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- }
-
- // Attach/detach the controller to the live scroll element + content (disposed on
- // unmount). The content element is observed (ResizeObserver) so the view follows
- // height changes that aren't a transcript append.
- $effect(() => {
- if (!transcriptEl) return;
- return smartScroll.attach(transcriptEl, transcriptContentEl);
- });
-
- // New transcript content streamed in (or messages loaded) → follow the bottom
- // while stuck. Reads `chunks.length` so the effect re-runs on every append.
- $effect(() => {
- void store.activeChat.chunks.length;
- smartScroll.contentChanged();
- });
-
- // The message-queue surface spec + whether it currently has pending messages
- // (steering). Rendered as a compact panel above the composer only when non-empty.
- const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
- const hasQueuedMessages = $derived.by(() => {
- const spec = messageQueueSpec;
- if (spec === null) return false;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
- if (field === undefined || field.kind !== "custom") return false;
- const data = parseMessageQueuePayload(field.payload);
- return data !== null && data.messages.length > 0;
- });
-
- // The todo surface spec + its parsed task list (model-maintained, read-only).
- const todoSpec = $derived(store.surface(TODO_ID));
- const todoData = $derived.by(() => {
- const spec = todoSpec;
- if (spec === null) return null;
- const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
- if (field === undefined || field.kind !== "custom") return null;
- return parseTodoPayload(field.payload);
- });
-
- // Conversation/tab switch → snap to the bottom of the new transcript.
- $effect(() => {
- void store.activeConversationId;
- smartScroll.reset();
- });
-
- // Right sidebar: persisted open/closed state. Defaults to open on wide
- // screens (first visit), then remembers the user's toggle thereafter.
- const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
- const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
- storage: untrack(() => store.storage),
- });
- const storedSidebarOpen = sidebarOpenStore.load();
- let sidebarOpen = $state(
- storedSidebarOpen ??
- (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true),
- );
- let systemPromptModalOpen = $state(false);
-
- $effect(() => {
- sidebarOpenStore.save(sidebarOpen);
- });
-
- function handleInvoke(msg: InvokeMessage) {
- store.invoke(msg.surfaceId, msg.actionId, msg.payload);
- }
-
- function handleSend(text: string) {
- store.send(text);
- }
-
- function handleQueue(text: string) {
- store.queueMessage(text);
- }
-
- function handleStop() {
- store.stopGeneration();
- }
-
- function handleSelectModel(model: string) {
- store.selectModel(model);
- }
-
- // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
- async function warmNow(): Promise<WarmFeedback | null> {
- const result = await store.warmNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- cachePct: result.response.cachePct,
- expectedCacheRate: result.response.expectedCacheRate,
- }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's reasoning-effort result to the chat feature's port.
- async function saveReasoningEffort(
- level: ReasoningEffort,
- ): Promise<ReasoningEffortSaveResult | null> {
- const result = await store.setReasoningEffort(level);
- if (result === null) return null;
- return result.ok
- ? { ok: true, reasoningEffort: result.reasoningEffort }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's compact result to the compaction view's port.
- async function compactNow(): Promise<CompactNowResult | null> {
- const result = await store.compactNow();
- if (result === null) return null;
- return result.ok
- ? {
- ok: true,
- messagesSummarized: result.response.messagesSummarized,
- messagesKept: result.response.messagesKept,
- }
- : { ok: false, error: result.error };
- }
-
- async function saveCompactPercent(percent: number): Promise<SaveCompactPercentResult | null> {
- const result = await store.setCompactPercent(percent);
- if (result === null) return null;
- return result.ok ? { ok: true, percent: result.percent } : { ok: false, error: result.error };
- }
-
- // Adapt the store's chat-limit result to the settings feature's port. On a
- // raise the active chat refills (prepends older history); preserve the
- // reader's viewport over the prepend (the manual analogue of CSS scroll
- // anchoring), exactly like `handleShowEarlier`.
- async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
- const el = transcriptEl;
- const prevHeight = el?.scrollHeight ?? 0;
- const prevTop = el?.scrollTop ?? 0;
- const result = await store.setChatLimit(value);
- await tick();
- if (el) {
- const delta = el.scrollHeight - prevHeight;
- if (delta > 0) el.scrollTop = prevTop + delta;
- }
- return result.ok
- ? { ok: true, chatLimit: result.chatLimit }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
- async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
- const result = await store.setCwd(cwd);
- if (result === null) return null;
- return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
- }
-
- async function loadLspStatus(): Promise<LspStatusResult | null> {
- const result = await store.lspStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's computer results to the computer feature's ports.
- async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
- const result = await store.setComputer(computerId);
- if (result === null) return null;
- return result.ok
- ? { ok: true, computerId: result.computerId }
- : { ok: false, error: result.error };
- }
-
- const loadComputerStatus: LoadComputerStatus = async (
- alias: string,
- ): Promise<ComputerStatusResult | null> => {
- const result = await store.computerStatus(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
- };
-
- const testComputer: TestComputer = async (alias: string): Promise<TestComputerResult | null> => {
- const result = await store.testComputer(alias);
- if (result === null) return null;
- return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
- };
-
- async function loadMcpStatus(): Promise<McpStatusResult | null> {
- const result = await store.mcpStatus();
- if (result === null) return null;
- return result.ok
- ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
- : { ok: false, error: result.error };
- }
-
- // Adapt the store's system prompt results to the system-prompt feature's ports.
- const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
-
- const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
- store.loadSystemPromptVariables();
-
- const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) =>
- store.setSystemPrompt(template);
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import type { InvokeMessage } from "@dispatch/ui-contract";
+ import { tick } from "svelte";
+ import Table from "../components/Table.svelte";
+ import {
+ CacheWarmingView,
+ manifest as cacheWarmingManifest,
+ type WarmFeedback,
+ } from "../features/cache-warming";
+ import {
+ ChatView,
+ CompactionView,
+ Composer,
+ manifest as chatManifest,
+ ModelSelector,
+ ReasoningEffortSelector,
+ type CompactNowResult,
+ type ReasoningEffortSaveResult,
+ type SaveCompactPercentResult,
+ } from "../features/chat";
+ import { manifest as conversationCacheManifest } from "../features/conversation-cache";
+ import { manifest as markdownManifest } from "../features/markdown";
+ import {
+ McpStatusView,
+ manifest as mcpManifest,
+ type McpStatusResult,
+ } from "../features/mcp";
+ import {
+ ChatLimitField,
+ manifest as settingsManifest,
+ type ChatLimitSaveResult,
+ } from "../features/settings";
+ import {
+ createSmartScrollController,
+ manifest as smartScrollManifest,
+ ScrollToBottom,
+ } from "../features/smart-scroll";
+ import { manifest as surfaceHostManifest, SurfaceView } from "../features/surface-host";
+ import { parseMessageQueuePayload } from "../features/surface-host/logic/message-queue";
+ import { parseTodoPayload } from "../features/surface-host/logic/todo";
+ import TodoList from "../features/surface-host/ui/TodoList.svelte";
+ import { manifest as tabsManifest, TabBar } from "../features/tabs";
+ import { manifest as viewsManifest, ViewSidebar } from "../features/views";
+ import {
+ CwdField,
+ type CwdSaveResult,
+ LspStatusView,
+ type LspStatusResult,
+ manifest as cwdLspManifest,
+ } from "../features/cwd-lsp";
+ import {
+ ComputerField,
+ manifest as computerManifest,
+ type ComputerSaveResult,
+ type ComputerStatusResult,
+ type LoadComputerStatus,
+ type SaveComputer,
+ type TestComputer,
+ type TestComputerResult,
+ } from "../features/computer";
+ import {
+ HeartbeatView,
+ manifest as heartbeatManifest,
+ RunModal,
+ type HeartbeatConfigResult,
+ type HeartbeatNextRunResult,
+ type HeartbeatRunView,
+ type HeartbeatRunsResult,
+ type HeartbeatStopResult,
+ } from "../features/heartbeat";
+ import type { ChatStore } from "../features/chat";
+ import {
+ SystemPromptBuilder,
+ type LoadSystemPrompt as LoadSystemPromptAlias,
+ type LoadSystemPromptVariables as LoadSystemPromptVariablesAlias,
+ type SaveSystemPrompt as SaveSystemPromptAlias,
+ manifest as systemPromptManifest,
+ } from "../features/system-prompt";
+ import type { AppStore } from "./store.svelte";
+ import ErrorModal from "./ErrorModal.svelte";
+ import { createLocalStore } from "../adapters/local-storage";
+ import { untrack } from "svelte";
+
+ let { store }: { store: AppStore } = $props();
+
+ // The backend's conversation-scoped cache-warming surface. Referenced by id at
+ // the composition root (sanctioned discovery-by-id) to give it a dedicated view
+ // and keep it out of the generic Extensions surface list — SurfaceView itself
+ // stays fully generic (it never switches on a surface id).
+ const CACHE_WARMING_ID = "cache-warming";
+ // The message-queue extension's per-conversation surface (steering). Pulled
+ // out of the generic Extensions list and rendered as a compact panel above the
+ // composer — pending steering messages are tied to the chat, not the sidebar.
+ const MESSAGE_QUEUE_ID = "message-queue";
+ // The `todo` extension's per-conversation task list surface (model-maintained).
+ const TODO_ID = "todo";
+
+ // The view kinds offered in the sidebar's dropdown. Generic data — the
+ // `viewContent` snippet below maps each kind id to its renderer.
+ const viewKinds = [
+ { id: "model", label: "Model" },
+ { id: "lsp", label: "Language Servers" },
+ { id: "mcp", label: "MCP Servers" },
+ { id: "extensions", label: "Extensions" },
+ { id: "cache-warming", label: "Cache Warming" },
+ { id: "tasks", label: "Tasks" },
+ { id: "compaction", label: "Compaction" },
+ { id: "heartbeat", label: "Heartbeat" },
+ { id: "system-prompt", label: "System Prompt" },
+ { id: "settings", label: "Settings" },
+ ] as const;
+
+ // Default sidebar layout: just the Model view.
+ const DEFAULT_VIEWS: readonly string[] = ["model"];
+ const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", {
+ storage: untrack(() => store.storage),
+ });
+ const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS;
+
+ function handleSidebarChange(kinds: readonly (string | null)[]): void {
+ sidebarStore.save(kinds.filter((k): k is string => k !== null));
+ }
+
+ // Frontend module list for the "Loaded Modules" view, AGGREGATED from each
+ // feature's public `manifest` export so it can't drift from what's actually
+ // composed. (The backend's "Loaded Extensions" surface is a SEPARATE,
+ // backend-owned list.) FE features are internal units of this single repo, so
+ // there is no per-module version — they all share dispatch-web's version.
+ const MODULE_COLUMNS = ["Module", "Description"] as const;
+ const loadedModules: readonly (readonly [string, string])[] = [
+ chatManifest,
+ tabsManifest,
+ surfaceHostManifest,
+ viewsManifest,
+ conversationCacheManifest,
+ markdownManifest,
+ cacheWarmingManifest,
+ cwdLspManifest,
+ mcpManifest,
+ computerManifest,
+ smartScrollManifest,
+ settingsManifest,
+ systemPromptManifest,
+ heartbeatManifest,
+ ].map((m) => [m.name, m.description] as const);
+
+ // Smart-scroll: keep the transcript pinned to the bottom while it streams,
+ // unless the reader has scrolled up (then show a "scroll to bottom" button).
+ // One controller owns the chat scroll region; effects below feed it the edges.
+ const smartScroll = createSmartScrollController();
+ let transcriptEl = $state<HTMLElement | undefined>();
+ let transcriptContentEl = $state<HTMLElement | undefined>();
+
+ // Chat-limit unload gate: old chunks may be unloaded only while the reader is
+ // stuck to the bottom. While stuck, a trim removes content far ABOVE the
+ // viewport and the controller re-pins to the bottom — no visible jump; while
+ // reading history, trimming is deferred instead of yanking the page (the old
+ // Dispatch bug). In an $effect so a swapped store prop would be re-wired.
+ $effect(() => {
+ store.attachUnloadGate(() => smartScroll.isAtBottom());
+ });
+
+ // "Show earlier messages": page older history back in, preserving the reader's
+ // viewport position — prepended content grows scrollHeight, so shift scrollTop
+ // by the growth (the manual analogue of CSS scroll anchoring, which not every
+ // engine applies here).
+ async function handleShowEarlier(): Promise<void> {
+ const el = transcriptEl;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ await store.activeChat.showEarlier();
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ }
+
+ // Attach/detach the controller to the live scroll element + content (disposed on
+ // unmount). The content element is observed (ResizeObserver) so the view follows
+ // height changes that aren't a transcript append.
+ $effect(() => {
+ if (!transcriptEl) return;
+ return smartScroll.attach(transcriptEl, transcriptContentEl);
+ });
+
+ // New transcript content streamed in (or messages loaded) → follow the bottom
+ // while stuck. Reads `chunks.length` so the effect re-runs on every append.
+ $effect(() => {
+ void store.activeChat.chunks.length;
+ smartScroll.contentChanged();
+ });
+
+ // The message-queue surface spec + whether it currently has pending messages
+ // (steering). Rendered as a compact panel above the composer only when non-empty.
+ const messageQueueSpec = $derived(store.surface(MESSAGE_QUEUE_ID));
+ const hasQueuedMessages = $derived.by(() => {
+ const spec = messageQueueSpec;
+ if (spec === null) return false;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === MESSAGE_QUEUE_ID);
+ if (field === undefined || field.kind !== "custom") return false;
+ const data = parseMessageQueuePayload(field.payload);
+ return data !== null && data.messages.length > 0;
+ });
+
+ // The todo surface spec + its parsed task list (model-maintained, read-only).
+ const todoSpec = $derived(store.surface(TODO_ID));
+ const todoData = $derived.by(() => {
+ const spec = todoSpec;
+ if (spec === null) return null;
+ const field = spec.fields.find((f) => f.kind === "custom" && f.rendererId === TODO_ID);
+ if (field === undefined || field.kind !== "custom") return null;
+ return parseTodoPayload(field.payload);
+ });
+
+ // Conversation/tab switch → snap to the bottom of the new transcript.
+ $effect(() => {
+ void store.activeConversationId;
+ smartScroll.reset();
+ });
+
+ // Right sidebar: persisted open/closed state. Defaults to open on wide
+ // screens (first visit), then remembers the user's toggle thereafter.
+ const WIDE_BREAKPOINT = 1024; // Tailwind `lg`
+ const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", {
+ storage: untrack(() => store.storage),
+ });
+ const storedSidebarOpen = sidebarOpenStore.load();
+ let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true));
+ let systemPromptModalOpen = $state(false);
+ // The heartbeat run currently open in the fullscreen run-chat modal (null =
+ // closed). Holds a snapshot run view; the modal re-mounts per run (keyed).
+ let heartbeatRun = $state<HeartbeatRunView | null>(null);
+
+ $effect(() => {
+ sidebarOpenStore.save(sidebarOpen);
+ });
+
+ function handleInvoke(msg: InvokeMessage) {
+ store.invoke(msg.surfaceId, msg.actionId, msg.payload);
+ }
+
+ function handleSend(text: string) {
+ store.send(text);
+ }
+
+ function handleQueue(text: string) {
+ store.queueMessage(text);
+ }
+
+ function handleStop() {
+ store.stopGeneration();
+ }
+
+ function handleSelectModel(model: string) {
+ store.selectModel(model);
+ }
+
+ // Adapt the store's WarmResult to the cache-warming feature's WarmNow port.
+ async function warmNow(): Promise<WarmFeedback | null> {
+ const result = await store.warmNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ cachePct: result.response.cachePct,
+ expectedCacheRate: result.response.expectedCacheRate,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's reasoning-effort result to the chat feature's port.
+ async function saveReasoningEffort(
+ level: ReasoningEffort,
+ ): Promise<ReasoningEffortSaveResult | null> {
+ const result = await store.setReasoningEffort(level);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, reasoningEffort: result.reasoningEffort }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's compact result to the compaction view's port.
+ async function compactNow(): Promise<CompactNowResult | null> {
+ const result = await store.compactNow();
+ if (result === null) return null;
+ return result.ok
+ ? {
+ ok: true,
+ messagesSummarized: result.response.messagesSummarized,
+ messagesKept: result.response.messagesKept,
+ }
+ : { ok: false, error: result.error };
+ }
+
+ async function saveCompactPercent(
+ percent: number,
+ ): Promise<SaveCompactPercentResult | null> {
+ const result = await store.setCompactPercent(percent);
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, percent: result.percent }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's chat-limit result to the settings feature's port. On a
+ // raise the active chat refills (prepends older history); preserve the
+ // reader's viewport over the prepend (the manual analogue of CSS scroll
+ // anchoring), exactly like `handleShowEarlier`.
+ async function saveChatLimit(value: number): Promise<ChatLimitSaveResult> {
+ const el = transcriptEl;
+ const prevHeight = el?.scrollHeight ?? 0;
+ const prevTop = el?.scrollTop ?? 0;
+ const result = await store.setChatLimit(value);
+ await tick();
+ if (el) {
+ const delta = el.scrollHeight - prevHeight;
+ if (delta > 0) el.scrollTop = prevTop + delta;
+ }
+ return result.ok
+ ? { ok: true, chatLimit: result.chatLimit }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's cwd/LSP results to the cwd-lsp feature's ports.
+ async function saveCwd(cwd: string): Promise<CwdSaveResult | null> {
+ const result = await store.setCwd(cwd);
+ if (result === null) return null;
+ return result.ok ? { ok: true, cwd: result.cwd } : { ok: false, error: result.error };
+ }
+
+ async function loadLspStatus(): Promise<LspStatusResult | null> {
+ const result = await store.lspStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's computer results to the computer feature's ports.
+ async function saveComputer(computerId: string | null): Promise<ComputerSaveResult | null> {
+ const result = await store.setComputer(computerId);
+ if (result === null) return null;
+ return result.ok ? { ok: true, computerId: result.computerId } : { ok: false, error: result.error };
+ }
+
+ const loadComputerStatus: LoadComputerStatus = async (
+ alias: string,
+ ): Promise<ComputerStatusResult | null> => {
+ const result = await store.computerStatus(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, status: result.response } : { ok: false, error: result.error };
+ };
+
+ const testComputer: TestComputer = async (
+ alias: string,
+ ): Promise<TestComputerResult | null> => {
+ const result = await store.testComputer(alias);
+ if (result === null) return null;
+ return result.ok ? { ok: true, response: result.response } : { ok: false, error: result.error };
+ };
+
+ async function loadMcpStatus(): Promise<McpStatusResult | null> {
+ const result = await store.mcpStatus();
+ if (result === null) return null;
+ return result.ok
+ ? { ok: true, cwd: result.response.cwd, servers: result.response.servers }
+ : { ok: false, error: result.error };
+ }
+
+ // Adapt the store's system prompt results to the system-prompt feature's ports.
+ const loadSystemPromptPrompt: LoadSystemPromptAlias = () => store.loadSystemPrompt();
+
+ const loadSystemPromptVariablesPrompt: LoadSystemPromptVariablesAlias = () =>
+ store.loadSystemPromptVariables();
+
+ const saveSystemPromptPrompt: SaveSystemPromptAlias = (template) => store.setSystemPrompt(template);
+
+ // Adapt the store's heartbeat results to the heartbeat feature's ports. The
+ // store returns the feature's result types directly (the API is a plain REST
+ // surface, not a transport-contract type), so the adapter is a thin passthrough
+ // (kept for structural consistency with cwd-lsp/mcp/computer — see AGENTS.md
+ // "contracts are the cross-unit surface").
+ async function loadHeartbeatConfig(): Promise<HeartbeatConfigResult> {
+ return store.heartbeatConfig();
+ }
+
+ async function saveHeartbeatConfig(
+ patch: Parameters<typeof store.setHeartbeatConfig>[0],
+ ): Promise<HeartbeatConfigResult> {
+ return store.setHeartbeatConfig(patch);
+ }
+
+ async function loadHeartbeatRuns(): Promise<HeartbeatRunsResult> {
+ return store.heartbeatRuns();
+ }
+
+ async function stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ return store.stopHeartbeatRun(runId);
+ }
+
+ async function loadHeartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ return store.heartbeatNextRun();
+ }
+
+ // Run-chat modal: open a live watch on the run's conversation (the store owns
+ // the ChatStore + the `chat.subscribe` stream), and tear it down on close.
+ function openRunChat(conversationId: string): ChatStore {
+ return store.watchConversation(conversationId);
+ }
+ function closeRunChat(conversationId: string): void {
+ store.unwatchConversation(conversationId);
+ }
</script>
<main class="relative flex h-screen overflow-hidden">
- <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
- (below), so opening it shrinks this ENTIRE column — tab row included, which
- slides the hamburger left. -->
- <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
- <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
- a permanently seated hamburger pinned to the far right. -->
- <div class="flex min-w-0 items-center">
- <TabBar
- tabs={store.tabs}
- activeConversationId={store.activeConversationId}
- statusFor={(id) => store.conversationStatus(id)}
- onSelect={(id) => store.selectTab(id)}
- onClose={(id) => store.closeTab(id)}
- onNewDraft={() => store.newDraft()}
- onRename={(id, title) => store.renameTab(id, title)}
- />
- <span
- class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
- title="Build version (git short hash)"
- >
- {__APP_VERSION__}
- </span>
- <button
- class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
- aria-label="Toggle sidebar"
- aria-expanded={sidebarOpen}
- onclick={() => (sidebarOpen = !sidebarOpen)}
- >
- <svg
- xmlns="http://www.w3.org/2000/svg"
- fill="none"
- viewBox="0 0 24 24"
- stroke-width="2"
- stroke="currentColor"
- class="size-5"
- aria-hidden="true"
- >
- <path
- stroke-linecap="round"
- stroke-linejoin="round"
- d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
- />
- </svg>
- </button>
- </div>
-
- {#if store.lastError}
- <div role="alert" class="alert alert-error mx-4 mt-2">
- <strong>Error:</strong>
- {store.lastError.message}
- </div>
- {/if}
-
- {#if store.activeChat.error}
- <div role="alert" class="alert alert-warning mx-4 mt-2">
- <strong>Chat error:</strong>
- {store.activeChat.error}
- </div>
- {/if}
-
- <div class="relative min-h-0 min-w-0 flex-1">
- <div bind:this={transcriptEl} class="h-full overflow-y-auto">
- <div bind:this={transcriptContentEl}>
- {#key store.activeConversationId}
- <ChatView
- chunks={store.activeChat.chunks}
- turnMetrics={store.activeChat.turnMetrics}
- hasEarlier={store.activeChat.hasEarlier}
- onShowEarlier={handleShowEarlier}
- thinkingKeyBase={store.activeChat.thinkingKeyBase}
- providerRetry={store.activeChat.providerRetry}
- />
- {/key}
- </div>
- </div>
- {#if store.activeChat.chunks.length === 0}
- <div
- class="pointer-events-none absolute inset-0 flex items-center justify-center"
- aria-hidden="true"
- >
- <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
- </div>
- {/if}
- <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
- </div>
-
- {#if hasQueuedMessages && messageQueueSpec !== null}
- <!-- Pending steering messages (the message-queue surface). Rendered via
- the generic SurfaceView (dispatches on rendererId, never surface id);
- only shown when the queue is non-empty — an idle queue is hidden. -->
- <div class="px-4 pt-2">
- <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
- </div>
- {/if}
-
- <Composer
- onSend={handleSend}
- onQueue={handleQueue}
- onStop={handleStop}
- contextSize={store.activeChat.currentContextSize}
- contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
- status={store.activeChat.error ? "error" : store.activeChat.generating ? "running" : "idle"}
- />
- </div>
-
- <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
- opening it shrinks the whole left column (push). Below `lg` it overlays
- (`max-lg:absolute`, full height) with a backdrop. -->
- <aside
- class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
- class:w-80={sidebarOpen}
- class:w-0={!sidebarOpen}
- >
- <div
- class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
- style="transform: translateX({sidebarOpen ? '0' : '100%'})"
- >
- <ViewSidebar
- kinds={viewKinds}
- initial={sidebarPanels}
- onChange={handleSidebarChange}
- content={viewContent}
- />
- </div>
- </aside>
-
- <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
- {#if sidebarOpen}
- <!-- svelte-ignore a11y_no_static_element_interactions -->
- <div
- class="fixed inset-0 z-20 bg-black/30 lg:hidden"
- role="button"
- tabindex="0"
- aria-label="Close sidebar"
- onclick={() => (sidebarOpen = false)}
- onkeydown={(e) => {
- if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
- }}
- ></div>
- {/if}
+ <!-- LEFT: everything except the sidebar. The full-height sidebar is a sibling
+ (below), so opening it shrinks this ENTIRE column — tab row included, which
+ slides the hamburger left. -->
+ <div class="flex min-w-0 flex-1 flex-col overflow-hidden pt-[5px]">
+ <!-- Tab row: the tab strip fills + scrolls internally (flex-1 min-w-0), with
+ a permanently seated hamburger pinned to the far right. -->
+ <div class="flex min-w-0 items-center">
+ <TabBar
+ tabs={store.tabs}
+ activeConversationId={store.activeConversationId}
+ statusFor={(id) => store.conversationStatus(id)}
+ onSelect={(id) => store.selectTab(id)}
+ onClose={(id) => store.closeTab(id)}
+ onNewDraft={() => store.newDraft()}
+ onRename={(id, title) => store.renameTab(id, title)}
+ />
+ <span
+ class="shrink-0 select-none px-1 font-mono text-[10px] leading-none text-base-content/30"
+ title="Build version (git short hash)"
+ >
+ {__APP_VERSION__}
+ </span>
+ <button
+ class="btn btn-square btn-ghost btn-sm mx-1 shrink-0"
+ aria-label="Toggle sidebar"
+ aria-expanded={sidebarOpen}
+ onclick={() => (sidebarOpen = !sidebarOpen)}
+ >
+ <svg
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="2"
+ stroke="currentColor"
+ class="size-5"
+ aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12h16.5M3.75 17.25h16.5"
+ />
+ </svg>
+ </button>
+ </div>
+
+ {#if store.lastError}
+ <div role="alert" class="alert alert-error mx-4 mt-2">
+ <strong>Error:</strong>
+ {store.lastError.message}
+ </div>
+ {/if}
+
+ {#if store.activeChat.error}
+ <div role="alert" class="alert alert-warning mx-4 mt-2">
+ <strong>Chat error:</strong>
+ {store.activeChat.error}
+ </div>
+ {/if}
+
+ <div class="relative min-h-0 min-w-0 flex-1">
+ <div bind:this={transcriptEl} class="h-full overflow-y-auto">
+ <div bind:this={transcriptContentEl}>
+ {#key store.activeConversationId}
+ <ChatView
+ chunks={store.activeChat.chunks}
+ turnMetrics={store.activeChat.turnMetrics}
+ hasEarlier={store.activeChat.hasEarlier}
+ onShowEarlier={handleShowEarlier}
+ thinkingKeyBase={store.activeChat.thinkingKeyBase}
+ providerRetry={store.activeChat.providerRetry}
+ />
+ {/key}
+ </div>
+ </div>
+ {#if store.activeChat.chunks.length === 0}
+ <div
+ class="pointer-events-none absolute inset-0 flex items-center justify-center"
+ aria-hidden="true"
+ >
+ <span class="select-none text-4xl font-bold opacity-10">Dispatch</span>
+ </div>
+ {/if}
+ <ScrollToBottom show={smartScroll.showButton} onResume={() => smartScroll.resume()} />
+ </div>
+
+ {#if hasQueuedMessages && messageQueueSpec !== null}
+ <!-- Pending steering messages (the message-queue surface). Rendered via
+ the generic SurfaceView (dispatches on rendererId, never surface id);
+ only shown when the queue is non-empty — an idle queue is hidden. -->
+ <div class="px-4 pt-2">
+ <SurfaceView spec={messageQueueSpec} onInvoke={handleInvoke} />
+ </div>
+ {/if}
+
+ <Composer
+ onSend={handleSend}
+ onQueue={handleQueue}
+ onStop={handleStop}
+ contextSize={store.activeChat.currentContextSize}
+ contextWindow={store.modelInfo[store.activeModel]?.contextWindow}
+ status={store.activeChat.error
+ ? "error"
+ : store.activeChat.generating
+ ? "running"
+ : "idle"}
+ />
+ </div>
+
+ <!-- Full-height right sidebar. On wide screens (`lg:relative`) it is in-flow, so
+ opening it shrinks the whole left column (push). Below `lg` it overlays
+ (`max-lg:absolute`, full height) with a backdrop. -->
+ <aside
+ class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
+ class:w-80={sidebarOpen}
+ class:w-0={!sidebarOpen}
+ >
+ <div
+ class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out"
+ style="transform: translateX({sidebarOpen ? '0' : '100%'})"
+ >
+ <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} />
+ </div>
+ </aside>
+
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ {#if sidebarOpen}
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 z-20 bg-black/30 lg:hidden"
+ role="button"
+ tabindex="0"
+ aria-label="Close sidebar"
+ onclick={() => (sidebarOpen = false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") sidebarOpen = false;
+ }}
+ ></div>
+ {/if}
</main>
{#if store.fatalError}
- <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
+ <ErrorModal error={store.fatalError} onDismiss={() => store.clearFatalError()} />
{/if}
{#if systemPromptModalOpen}
- <SystemPromptBuilder
- loadPrompt={loadSystemPromptPrompt}
- savePrompt={saveSystemPromptPrompt}
- loadVariables={loadSystemPromptVariablesPrompt}
- onClose={() => (systemPromptModalOpen = false)}
- />
+ <SystemPromptBuilder
+ loadPrompt={loadSystemPromptPrompt}
+ savePrompt={saveSystemPromptPrompt}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ onClose={() => (systemPromptModalOpen = false)}
+ />
+{/if}
+
+{#if heartbeatRun !== null}
+ <!-- Keyed per run so switching runs (or re-opening) re-mounts the modal — a
+ fresh watch store lifecycle per run. The modal owns the live watch
+ (openChat/closeChat) and the Stop button. -->
+ {#key heartbeatRun.id}
+ <RunModal
+ run={heartbeatRun}
+ openChat={openRunChat}
+ closeChat={closeRunChat}
+ stopRun={stopHeartbeatRun}
+ onClose={() => (heartbeatRun = null)}
+ />
+ {/key}
{/if}
{#snippet viewContent(kind: string)}
- {#if kind === "model"}
- <div class="flex flex-col gap-3">
- <ModelSelector
- models={store.models}
- selected={store.activeModel}
- onSelect={handleSelectModel}
- />
- <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
- re-mount per conversation — incl. switching between drafts — and can't
- bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
- {#key store.currentConversationId}
- <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
- <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
- <ComputerField
- computerId={store.computerId}
- canEdit={true}
- computers={store.computers}
- save={saveComputer}
- loadStatus={loadComputerStatus}
- test={testComputer}
- />
- {/key}
- </div>
- {:else if kind === "lsp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
- {/key}
- {:else if kind === "mcp"}
- <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
- {#key store.currentConversationId}
- <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
- {/key}
- {:else if kind === "extensions"}
- <section>
- <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
- <Table columns={MODULE_COLUMNS} rows={loadedModules} />
- </section>
- <section class="mt-4 flex flex-col gap-3">
- <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
- {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
- <SurfaceView {spec} onInvoke={handleInvoke} />
- {/each}
- </section>
- {:else if kind === "cache-warming"}
- <!-- Re-mount per conversation (like ChatView) so the view's local warming
- history / manual-warm feedback can't bleed across tabs. -->
- {#key store.activeConversationId}
- <CacheWarmingView
- spec={store.surface(CACHE_WARMING_ID)}
- canWarm={store.activeConversationId !== null}
- onInvoke={handleInvoke}
- {warmNow}
- />
- {/key}
- {:else if kind === "tasks"}
- <!-- Re-mount per conversation so the task list is isolated per conversation. -->
- {#key store.activeConversationId}
- {#if todoData !== null && todoData.todos.length > 0}
- <TodoList payload={todoData} />
- {:else}
- <p class="text-xs opacity-60">No tasks yet.</p>
- {/if}
- {/key}
- {:else if kind === "compaction"}
- <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. -->
- {#key store.currentConversationId}
- <CompactionView
- percent={store.compactPercent}
- canCompact={store.activeConversationId !== null}
- {compactNow}
- savePercent={saveCompactPercent}
- />
- {/key}
- {:else if kind === "system-prompt"}
- <!-- Global system prompt template. Opens a full-page modal editor (half
- template / half variable palette). Not conversation-scoped (no {#key}). -->
- <div class="flex flex-col gap-2">
- <p class="text-xs opacity-60">
- Edit the global system prompt template with variable placeholders. Opens a full-page editor.
- </p>
- <button
- type="button"
- class="btn btn-primary btn-sm"
- onclick={() => (systemPromptModalOpen = true)}
- >
- Open builder
- </button>
- </div>
- {:else if kind === "settings"}
- <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
- global), so the field stays mounted across tab switches. -->
- <div class="flex flex-col gap-3">
- <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
- </div>
- {/if}
+ {#if kind === "model"}
+ <div class="flex flex-col gap-3">
+ <ModelSelector models={store.models} selected={store.activeModel} onSelect={handleSelectModel} />
+ <!-- Keyed on the workspace conversation (active tab OR draft) so the inputs
+ re-mount per conversation — incl. switching between drafts — and can't
+ bleed across tabs. Editable for a draft too (cwd + effort apply from turn 1). -->
+ {#key store.currentConversationId}
+ <ReasoningEffortSelector persisted={store.reasoningEffort} save={saveReasoningEffort} />
+ <CwdField cwd={store.cwd} canEdit={true} save={saveCwd} />
+ <ComputerField
+ computerId={store.computerId}
+ canEdit={true}
+ computers={store.computers}
+ save={saveComputer}
+ loadStatus={loadComputerStatus}
+ test={testComputer}
+ />
+ {/key}
+ </div>
+ {:else if kind === "lsp"}
+ <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
+ {#key store.currentConversationId}
+ <LspStatusView cwd={store.cwd} canView={true} load={loadLspStatus} />
+ {/key}
+ {:else if kind === "mcp"}
+ <!-- Re-mount per conversation (incl. draft) so the loaded server list is isolated. -->
+ {#key store.currentConversationId}
+ <McpStatusView cwd={store.cwd} canView={true} load={loadMcpStatus} />
+ {/key}
+ {:else if kind === "extensions"}
+ <section>
+ <h3 class="mb-1 text-xs font-semibold uppercase opacity-60">Frontend modules</h3>
+ <Table columns={MODULE_COLUMNS} rows={loadedModules} />
+ </section>
+ <section class="mt-4 flex flex-col gap-3">
+ <h3 class="text-xs font-semibold uppercase opacity-60">Surfaces</h3>
+ {#each store.surfaces.filter((s) => s.id !== CACHE_WARMING_ID && s.id !== MESSAGE_QUEUE_ID && s.id !== TODO_ID) as spec (spec.id)}
+ <SurfaceView {spec} onInvoke={handleInvoke} />
+ {/each}
+ </section>
+ {:else if kind === "cache-warming"}
+ <!-- Re-mount per conversation (like ChatView) so the view's local warming
+ history / manual-warm feedback can't bleed across tabs. -->
+ {#key store.activeConversationId}
+ <CacheWarmingView
+ spec={store.surface(CACHE_WARMING_ID)}
+ canWarm={store.activeConversationId !== null}
+ onInvoke={handleInvoke}
+ {warmNow}
+ />
+ {/key}
+ {:else if kind === "tasks"}
+ <!-- Re-mount per conversation so the task list is isolated per conversation. -->
+ {#key store.activeConversationId}
+ {#if todoData !== null && todoData.todos.length > 0}
+ <TodoList payload={todoData} />
+ {:else}
+ <p class="text-xs opacity-60">No tasks yet.</p>
+ {/if}
+ {/key}
+ {:else if kind === "compaction"}
+ <!-- Re-mount per conversation so the percent + feedback can't bleed across tabs. -->
+ {#key store.currentConversationId}
+ <CompactionView
+ percent={store.compactPercent}
+ canCompact={store.activeConversationId !== null}
+ {compactNow}
+ savePercent={saveCompactPercent}
+ />
+ {/key}
+ {:else if kind === "system-prompt"}
+ <!-- Global system prompt template. Opens a full-page modal editor (half
+ template / half variable palette). Not conversation-scoped (no {#key}). -->
+ <div class="flex flex-col gap-2">
+ <p class="text-xs opacity-60">
+ Edit the global system prompt template with variable placeholders. Opens a full-page editor.
+ </p>
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ onclick={() => (systemPromptModalOpen = true)}
+ >
+ Open builder
+ </button>
+ </div>
+ {:else if kind === "settings"}
+ <!-- FE-local settings. Not conversation-scoped (no {#key}: the chat limit is
+ global), so the field stays mounted across tab switches. -->
+ <div class="flex flex-col gap-3">
+ <ChatLimitField chatLimit={store.chatLimit} save={saveChatLimit} />
+ </div>
+ {:else if kind === "heartbeat"}
+ <!-- Workspace-scoped autonomous-agent heartbeat (config + run history).
+ Not conversation-scoped (no {#key}); the config + runs are per-workspace. -->
+ <HeartbeatView
+ models={store.models}
+ loadConfig={loadHeartbeatConfig}
+ saveConfig={saveHeartbeatConfig}
+ loadRuns={loadHeartbeatRuns}
+ stopRun={stopHeartbeatRun}
+ loadVariables={loadSystemPromptVariablesPrompt}
+ loadDefaultPrompt={loadSystemPromptPrompt}
+ loadNextRun={loadHeartbeatNextRun}
+ onOpenRun={(run) => (heartbeatRun = run)}
+ />
+ {/if}
{/snippet}
diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts
index 92fcd92..78f6ede 100644
--- a/src/app/store.svelte.ts
+++ b/src/app/store.svelte.ts
@@ -55,6 +55,16 @@ import type { ChatStore, HistorySync, MetricsSync } from "../features/chat";
import { createChatStore } from "../features/chat";
import type { ConversationCache } from "../features/conversation-cache";
import { createConversationCache } from "../features/conversation-cache";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+} from "../features/heartbeat";
+import { normalizeHeartbeatConfig, normalizeHeartbeatRuns } from "../features/heartbeat";
import type { Tab, TabsState } from "../features/tabs";
import { createTabsStore, deriveTitle, type TabsStore } from "../features/tabs";
import { resolveHttpUrl } from "./resolve-http-url";
@@ -306,6 +316,53 @@ export interface AppStore {
*/
attachUnloadGate(gate: () => boolean): void;
/**
+ * Load the active workspace's heartbeat config
+ * (`GET /workspaces/:id/heartbeat`). Workspace-scoped (NOT per-conversation):
+ * the backend runs an autonomous agent loop on a configured interval, writing
+ * each run into a dedicated conversation. The config covers the system/task
+ * prompts, model, reasoning effort, interval, and an enabled flag.
+ */
+ heartbeatConfig(): Promise<HeartbeatConfigResult>;
+ /**
+ * Persist a partial heartbeat config patch
+ * (`PUT /workspaces/:id/heartbeat`). The backend merges the patch onto the
+ * stored config; returns the full updated config.
+ */
+ setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult>;
+ /**
+ * Load the active workspace's heartbeat run history
+ * (`GET /workspaces/:id/heartbeat/runs`). Each run references the conversation
+ * it wrote to — open one via {@link watchConversation} to see its chat live.
+ */
+ heartbeatRuns(): Promise<HeartbeatRunsResult>;
+ /**
+ * Stop a running heartbeat run (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ * The run's in-flight turn seals (its conversation keeps streaming until it
+ * ends); the run's status flips to `stopped` (visible on the next runs poll).
+ */
+ stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult>;
+ /**
+ * Fetch the server-authoritative next-run timestamp
+ * (`GET /workspaces/:id/heartbeat/next-run`) — when the next heartbeat run
+ * will fire (ISO 8601), or null when disabled / no run scheduled. The FE shows
+ * a live countdown from this. When the endpoint is absent (404 — backend
+ * hasn't shipped CR-HB-3 yet) it returns `ok: false` so the FE falls back to
+ * an approximation from the runs + config.
+ */
+ heartbeatNextRun(): Promise<HeartbeatNextRunResult>;
+ /**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal): ensures a live {@link ChatStore} for the conversation, subscribing
+ * to its turn stream (`chat.subscribe`) + loading history. Reuses the open
+ * tab's store if the conversation is already a tab; otherwise creates an
+ * EPHEMERAL watch store (separate from tabs — never opens a tab). Deltas are
+ * routed to it automatically. Pair every open with {@link unwatchConversation}
+ * on close to unsubscribe + dispose the ephemeral store.
+ */
+ watchConversation(conversationId: string): ChatStore;
+ /** Dispose + unsubscribe a watch opened by {@link watchConversation}. */
+ unwatchConversation(conversationId: string): void;
+ /**
* A critical error that blocks normal operation (e.g. the cross-device tab
* restore fetch failed). When non-null, a full-screen modal is shown with the
* error details. Cleared by `clearFatalError` (the modal's dismiss button).
@@ -423,6 +480,13 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
const chatStores = new Map<string, ChatStore>();
+ // Ephemeral chat stores for MODAL viewers (the heartbeat run-chat modal): a
+ // watch on a conversation's live turn stream WITHOUT opening a tab. Separate
+ // from `chatStores` (tabs) so closing a modal never disturbs the tab strip,
+ // and a tab's conversation reuses its own store (see `watchConversation`).
+ // Deltas are routed here in addition to `chatStores`.
+ const watchStores = new Map<string, ChatStore>();
+
function createChatFor(conversationId: string, model: string, workspaceId: string): ChatStore {
return createChatStore({
conversationId,
@@ -592,7 +656,7 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
if (targetId !== undefined) {
- const store = chatStores.get(targetId);
+ const store = chatStores.get(targetId) ?? watchStores.get(targetId);
if (store !== undefined) {
store.handleDelta(msg);
return;
@@ -603,6 +667,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
for (const store of chatStores.values()) {
store.handleDelta(msg);
}
+ for (const store of watchStores.values()) {
+ store.handleDelta(msg);
+ }
}
/**
@@ -623,6 +690,44 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
/**
+ * Open a "watch" on a conversation for a modal viewer (the heartbeat run-chat
+ * modal). Returns a live {@link ChatStore} for the conversation's turn stream.
+ * If the conversation is already an open TAB, reuses its store (it is already
+ * subscribed + streaming); otherwise creates an EPHEMERAL watch store in
+ * `watchStores` (separate from tabs — never opens a tab), subscribes to its
+ * live turn stream, and loads history. Deltas route to it via `handleChatMessage`.
+ * Pair with {@link unwatchConversation} on close.
+ */
+ function watchConversation(conversationId: string): ChatStore {
+ // An open tab already has a live store + subscription — reuse it.
+ const tabStore = chatStores.get(conversationId);
+ if (tabStore !== undefined) return tabStore;
+ const existing = watchStores.get(conversationId);
+ if (existing !== undefined) return existing;
+ const store = createChatFor(conversationId, activeModel, activeWorkspaceId);
+ watchStores.set(conversationId, store);
+ void store.load();
+ subscribeChat(conversationId);
+ return store;
+ }
+
+ /**
+ * Dispose + unsubscribe a watch opened by {@link watchConversation}. A no-op if
+ * the conversation was (or became) an open TAB — the tab owns its store +
+ * subscription, so nothing is torn down (closing the modal must not disturb the
+ * tab strip). Only the ephemeral watch store is disposed + unsubscribed.
+ */
+ function unwatchConversation(conversationId: string): void {
+ // A tab reuses its own store — leave it (and its subscription) intact.
+ if (chatStores.has(conversationId)) return;
+ const store = watchStores.get(conversationId);
+ if (store === undefined) return;
+ store.dispose();
+ watchStores.delete(conversationId);
+ unsubscribeChat(conversationId);
+ }
+
+ /**
* Tell the backend the user EXPLICITLY closed this conversation's tab
* (`POST /conversations/:id/close`): aborts any in-flight turn (it seals with
* `reason: "aborted"`) and stops + DISABLES its cache-warming (persisted OFF).
@@ -876,6 +981,12 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
subscribeChat(tab.conversationId);
chatStores.get(tab.conversationId)?.resync();
}
+ // Re-attach to every MODAL watch too (a run-chat modal open across a
+ // reconnect keeps streaming). Watch stores are separate from tabs.
+ for (const [watchId, watchStore] of watchStores) {
+ subscribeChat(watchId);
+ watchStore.resync();
+ }
},
};
if (opts?.socketFactory !== undefined) {
@@ -1450,6 +1561,141 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
}
},
+ async heartbeatConfig(): Promise<HeartbeatConfigResult> {
+ // Workspace-scoped (NOT per-conversation): use the active workspace id.
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(`${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`);
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ // Normalize the untyped JSON at the network seam (pure helper) so a
+ // malformed/partial response can never crash the renderer.
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat config request failed",
+ };
+ }
+ },
+
+ async setHeartbeatConfig(patch: HeartbeatConfigPatch): Promise<HeartbeatConfigResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat`,
+ {
+ method: "PUT",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(patch),
+ },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Set heartbeat config failed (HTTP ${res.status})`,
+ };
+ }
+ const config: HeartbeatConfig = normalizeHeartbeatConfig(await res.json());
+ return { ok: true, config };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Set heartbeat config request failed",
+ };
+ }
+ },
+
+ async heartbeatRuns(): Promise<HeartbeatRunsResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs`,
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat runs failed (HTTP ${res.status})`,
+ };
+ }
+ const runs: readonly HeartbeatRun[] = normalizeHeartbeatRuns(await res.json());
+ return { ok: true, runs };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat runs request failed",
+ };
+ }
+ },
+
+ async stopHeartbeatRun(runId: string): Promise<HeartbeatStopResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/runs/${encodeURIComponent(runId)}/stop`,
+ { method: "POST" },
+ );
+ if (!res.ok) {
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Stop heartbeat run failed (HTTP ${res.status})`,
+ };
+ }
+ return { ok: true };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Stop heartbeat run request failed",
+ };
+ }
+ },
+
+ async heartbeatNextRun(): Promise<HeartbeatNextRunResult> {
+ const wsId = untrack(() => activeWorkspaceId);
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(wsId)}/heartbeat/next-run`,
+ );
+ if (!res.ok) {
+ // 404 = the backend hasn't shipped CR-HB-3 yet → the FE falls back to
+ // an approximation. Surface as ok:false (non-fatal).
+ const errBody = (await res.json().catch(() => null)) as { error?: string } | null;
+ return {
+ ok: false,
+ error: errBody?.error ?? `Heartbeat next-run failed (HTTP ${res.status})`,
+ };
+ }
+ const data = (await res.json().catch(() => null)) as { nextRunAt?: string | null } | null;
+ // `null` (disabled / no run scheduled) passes through; anything non-string
+ // also becomes null so a malformed body can't crash the countdown.
+ const raw = data?.nextRunAt;
+ const nextRunAt = typeof raw === "string" ? raw : null;
+ return { ok: true, nextRunAt };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Heartbeat next-run request failed",
+ };
+ }
+ },
+
+ watchConversation(conversationId: string): ChatStore {
+ return watchConversation(conversationId);
+ },
+
+ unwatchConversation(conversationId: string): void {
+ unwatchConversation(conversationId);
+ },
+
async loadSystemPrompt(): Promise<SystemPromptLoadResult> {
try {
const res = await fetchImpl(`${httpBase}/system-prompt`);
@@ -1531,6 +1777,10 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore {
store.dispose();
}
chatStores.clear();
+ for (const store of watchStores.values()) {
+ store.dispose();
+ }
+ watchStores.clear();
draftStore.dispose();
socket?.close();
socket = null;
diff --git a/src/app/store.test.ts b/src/app/store.test.ts
index 4523167..947a9b0 100644
--- a/src/app/store.test.ts
+++ b/src/app/store.test.ts
@@ -1124,4 +1124,255 @@ describe("createAppStore", () => {
store.dispose();
});
+
+ // ── Heartbeat (workspace-scoped config + runs + watch) ───────────────────────
+ //
+ // The heartbeat API is a plain REST surface (not a transport-contract type),
+ // so these tests fake the four endpoints + verify the store coerces the
+ // untyped JSON and routes live deltas to a watch store (the run-chat modal).
+
+ function heartbeatFetchImpl(opts?: {
+ config?: Record<string, unknown>;
+ runs?: Record<string, unknown>;
+ }): typeof fetch {
+ const base = fakeFetchImpl();
+ const config = opts?.config ?? {
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ };
+ const runs = opts?.runs ?? {
+ runs: [
+ {
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ triggeredAt: "2026-06-25T10:00:00Z",
+ status: "running",
+ },
+ ],
+ };
+ return async (input, init) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs") && method === "GET") {
+ return new Response(JSON.stringify(runs), { status: 200 });
+ }
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "GET") {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ // Echo the patch merged onto the stored config so the round-trip is observable.
+ const patch = init?.body ? JSON.parse(init.body as string) : {};
+ return new Response(JSON.stringify({ ...config, ...patch }), {
+ status: 200,
+ });
+ }
+ if (url.includes("/heartbeat")) {
+ return new Response(JSON.stringify(config), { status: 200 });
+ }
+ return base(input, init);
+ };
+ }
+
+ it("heartbeatConfig loads + coerces the workspace config", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: "medium",
+ });
+ store.dispose();
+ });
+
+ it("heartbeatConfig surfaces an HTTP error", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ if (url.endsWith("/heartbeat"))
+ return new Response(JSON.stringify({ error: "nope" }), { status: 500 });
+ return fakeFetchImpl()(input);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatConfig();
+ expect(result.ok).toBe(false);
+ if (result.ok) throw new Error("unreachable");
+ expect(result.error).toContain("nope");
+ store.dispose();
+ });
+
+ it("setHeartbeatConfig PUTs a patch and returns the merged config", async () => {
+ const calls: { url: string; method: string; body: unknown }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.endsWith("/heartbeat") && method === "PUT") {
+ calls.push({ url, method, body: JSON.parse(init?.body as string) });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.setHeartbeatConfig({ enabled: false, intervalMinutes: 9999 });
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.body).toEqual({ enabled: false, intervalMinutes: 9999 });
+ // The store normalizes the echoed response (interval clamped to the 1–1440 range).
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.config.intervalMinutes).toBe(1440);
+ store.dispose();
+ });
+
+ it("heartbeatRuns loads + coerces the run list", async () => {
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: heartbeatFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.heartbeatRuns();
+ expect(result.ok).toBe(true);
+ if (!result.ok) throw new Error("unreachable");
+ expect(result.runs).toHaveLength(1);
+ expect(result.runs[0]).toMatchObject({
+ id: "run-1",
+ conversationId: "hb-conv-1",
+ status: "running",
+ });
+ store.dispose();
+ });
+
+ it("stopHeartbeatRun POSTs the stop endpoint", async () => {
+ const calls: { url: string; method: string }[] = [];
+ const store = createAppStore({
+ socketFactory: () => fakeSocket(),
+ fetchImpl: async (input, init) => {
+ const url =
+ typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method ?? "GET";
+ if (url.includes("/heartbeat/runs/") && method === "POST") {
+ calls.push({ url, method });
+ }
+ return heartbeatFetchImpl()(input, init);
+ },
+ localStorage: createFakeStorage(),
+ });
+ const result = await store.stopHeartbeatRun("run-1");
+ expect(result.ok).toBe(true);
+ expect(calls).toHaveLength(1);
+ expect(calls[0]?.url).toContain("/heartbeat/runs/run-1/stop");
+ expect(calls[0]?.method).toBe("POST");
+ store.dispose();
+ });
+
+ it("watchConversation subscribes + routes live deltas to the watch store", async () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ // A heartbeat run's conversation that is NOT an open tab — watch it.
+ const watch = store.watchConversation("hb-conv-watch");
+ // A chat.subscribe was sent for the watched conversation.
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(subscribed).toBe(true);
+
+ // Feed a live delta for the watched conversation → the watch store folds it.
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: { type: "turn-start", conversationId: "hb-conv-watch", turnId: "t1" },
+ });
+ ws.feedServerMessage({
+ type: "chat.delta",
+ event: {
+ type: "text-delta",
+ conversationId: "hb-conv-watch",
+ turnId: "t1",
+ delta: "hello from heartbeat",
+ },
+ });
+
+ await vi.waitFor(() => {
+ const text = watch.chunks.find((c) => c.role === "assistant" && c.chunk.type === "text");
+ expect((text?.chunk as { type: "text"; text: string } | undefined)?.text).toBe(
+ "hello from heartbeat",
+ );
+ });
+ expect(watch.generating).toBe(true);
+
+ // Unwatch → unsubscribes (a chat.unsubscribe for this conversation is sent).
+ ws.sent.length = 0;
+ store.unwatchConversation("hb-conv-watch");
+ const unsubscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.unsubscribe" &&
+ (p as { conversationId?: string }).conversationId === "hb-conv-watch",
+ );
+ expect(unsubscribed).toBe(true);
+
+ store.dispose();
+ });
+
+ it("watchConversation reuses an open tab's store; unwatch is a no-op for it", () => {
+ const ws = fakeSocket();
+ const store = createAppStore({
+ socketFactory: () => ws,
+ fetchImpl: fakeFetchImpl(),
+ localStorage: createFakeStorage(),
+ });
+ ws.resolveOpen();
+
+ store.send("first");
+ const convId = activeConversationId(store);
+ // The conversation is an open tab (already subscribed on send). Watching it
+ // must REUSE the tab's store + subscription — so no NEW chat.subscribe is
+ // sent (the watch path only subscribes when it creates an ephemeral store).
+ // (Note: `store.activeChat` is a Svelte `$state` PROXY of the tab store, so a
+ // reference-equality check is meaningless here — we assert behavior instead.)
+ ws.sent.length = 0;
+ store.watchConversation(convId);
+ const subscribed = parseSent(ws).some(
+ (p) =>
+ (p as { type: string; conversationId?: string }).type === "chat.subscribe" &&
+ (p as { conversationId?: string }).conversationId === convId,
+ );
+ expect(subscribed).toBe(false);
+
+ // Unwatching a tab conversation does NOT unsubscribe (the tab keeps its stream).
+ ws.sent.length = 0;
+ store.unwatchConversation(convId);
+ const unsubscribed = parseSent(ws).some(
+ (p) => (p as { type: string }).type === "chat.unsubscribe",
+ );
+ expect(unsubscribed).toBe(false);
+
+ store.dispose();
+ });
});
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts
new file mode 100644
index 0000000..cc438f1
--- /dev/null
+++ b/src/features/heartbeat/index.ts
@@ -0,0 +1,50 @@
+export type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatNextRunResult,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatNextRun,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+} from "./logic/types";
+export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model";
+export {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./logic/view-model";
+export { default as HeartbeatView } from "./ui/HeartbeatView.svelte";
+export { default as PromptEditor } from "./ui/PromptEditor.svelte";
+export { default as RunModal } from "./ui/RunModal.svelte";
+
+/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
+export const manifest = {
+ name: "heartbeat",
+ description: "Workspace autonomous-agent heartbeat: config, run history, live run chat",
+} as const;
diff --git a/src/features/heartbeat/logic/types.ts b/src/features/heartbeat/logic/types.ts
new file mode 100644
index 0000000..3d3d525
--- /dev/null
+++ b/src/features/heartbeat/logic/types.ts
@@ -0,0 +1,103 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+
+/**
+ * Pure core types for the heartbeat feature — zero DOM, zero effects, zero Svelte.
+ *
+ * Heartbeat is a workspace-scoped autonomous agent loop: the backend periodically
+ * runs a turn in a dedicated conversation using a configured system prompt, task
+ * prompt, model, reasoning effort, and interval. The FE exposes the config
+ * (`GET`/`PUT /workspaces/:id/heartbeat`), the run history
+ * (`GET /workspaces/:id/heartbeat/runs`), and a per-run stop
+ * (`POST /workspaces/:id/heartbeat/runs/:runId/stop`).
+ *
+ * The backend's heartbeat API is a plain REST surface — it is NOT part of the
+ * shared `@dispatch/transport-contract` / `@dispatch/wire` packages (verified:
+ * no `heartbeat` symbol in either `dist/`). So, following the consumer-defines-
+ * port pattern (mirrors `features/mcp` / `features/computer` result types), the
+ * FE owns these shapes here and adapts the untyped JSON at the network seam in
+ * the composition root. If the backend later promotes these to a shared contract
+ * package, swap the local types for the imports (see `backend-handoff.md`).
+ */
+
+/** The canonical run lifecycle status (backend-owned enum, verbatim). */
+export type HeartbeatRunStatus = "running" | "completed" | "stopped";
+
+/** The workspace's heartbeat configuration (`GET /workspaces/:id/heartbeat`). */
+export interface HeartbeatConfig {
+ /** Whether the autonomous loop is enabled (running on the interval). */
+ readonly enabled: boolean;
+ readonly systemPrompt: string;
+ readonly taskPrompt: string;
+ /** Minutes between runs. */
+ readonly intervalMinutes: number;
+ /** The model name (`<credential>/<model>`) the heartbeat runs with. */
+ readonly model: string;
+ /**
+ * The heartbeat's reasoning effort, or null when never set (the server
+ * default `"high"` then applies) — mirrors the per-conversation knob's
+ * resolution chain.
+ */
+ readonly reasoningEffort: ReasoningEffort | null;
+}
+
+/**
+ * A partial config patch for `PUT /workspaces/:id/heartbeat`. Every field is
+ * optional — the backend merges the patch onto the stored config.
+ */
+export interface HeartbeatConfigPatch {
+ readonly enabled?: boolean;
+ readonly systemPrompt?: string;
+ readonly taskPrompt?: string;
+ readonly intervalMinutes?: number;
+ readonly model?: string;
+ readonly reasoningEffort?: ReasoningEffort | null;
+}
+
+/** One heartbeat run (`GET /workspaces/:id/heartbeat/runs`). */
+export interface HeartbeatRun {
+ readonly id: string;
+ /** The conversation this run wrote to (watch it live for the chat). */
+ readonly conversationId: string;
+ /** ISO timestamp of when the run was triggered. */
+ readonly triggeredAt: string;
+ readonly status: HeartbeatRunStatus;
+}
+
+// ── Injected ports (consumer-defines-port; the composition root adapts the
+// store's HTTP calls to these shapes). ──────────────────────────────────────
+
+/** Outcome of `GET /workspaces/:id/heartbeat` (or the PUT response). */
+export type HeartbeatConfigResult =
+ | { readonly ok: true; readonly config: HeartbeatConfig }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `GET /workspaces/:id/heartbeat/runs`. */
+export type HeartbeatRunsResult =
+ | { readonly ok: true; readonly runs: readonly HeartbeatRun[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of `POST /workspaces/:id/heartbeat/runs/:runId/stop`. */
+export type HeartbeatStopResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>;
+export type SaveHeartbeatConfig = (
+ patch: HeartbeatConfigPatch,
+) => Promise<HeartbeatConfigResult | null>;
+export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>;
+export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>;
+
+/**
+ * Outcome of `GET /workspaces/:id/heartbeat/next-run` — the server-authoritative
+ * timestamp of the next scheduled heartbeat run (ISO 8601 string), or `null`
+ * when the heartbeat is disabled or no run is scheduled. The FE computes a live
+ * countdown from this + a 1s clock (see `formatCountdown`). When the endpoint is
+ * unavailable (404 — backend hasn't shipped it yet), the FE falls back to an
+ * approximation from the runs + config (see `approximateNextRunEpoch`).
+ */
+export type HeartbeatNextRunResult =
+ | { readonly ok: true; readonly nextRunAt: string | null }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadHeartbeatNextRun = () => Promise<HeartbeatNextRunResult | null>;
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
new file mode 100644
index 0000000..aca0aa6
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -0,0 +1,470 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import type { HeartbeatConfig, HeartbeatRun } from "./types";
+import {
+ approximateNextRunEpoch,
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effectiveSystemPrompt,
+ effortOptions,
+ emptyForm,
+ formatCountdown,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ isInheritingSystemPrompt,
+ joinInterval,
+ nextRunEpoch,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ persistedSystemPrompt,
+ relativeLabel,
+ splitInterval,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./view-model";
+
+const NOW = Date.UTC(2026, 5, 25, 14, 30, 5); // 2026-06-25T14:30:05Z
+const ISO_AT = "2026-06-25T14:30:05Z"; // exactly NOW
+const run = (over: Partial<HeartbeatRun> = {}): HeartbeatRun => ({
+ id: "run-1",
+ conversationId: "conv-1",
+ triggeredAt: ISO_AT,
+ status: "completed",
+ ...over,
+});
+
+const config = (over: Partial<HeartbeatConfig> = {}): HeartbeatConfig => ({
+ enabled: false,
+ systemPrompt: "be helpful",
+ taskPrompt: "check status",
+ intervalMinutes: 15,
+ model: "openai/gpt-4o",
+ reasoningEffort: null,
+ ...over,
+});
+
+describe("badgeForStatus", () => {
+ it("running → warning + busy (spinner)", () => {
+ expect(badgeForStatus("running")).toEqual({ badge: "warning", busy: true });
+ });
+ it("completed → success, not busy", () => {
+ expect(badgeForStatus("completed")).toEqual({ badge: "success", busy: false });
+ });
+ it("stopped → neutral, not busy", () => {
+ expect(badgeForStatus("stopped")).toEqual({ badge: "neutral", busy: false });
+ });
+});
+
+describe("statusLabelFor", () => {
+ it("maps each status to a display label", () => {
+ expect(statusLabelFor("running")).toBe("Running");
+ expect(statusLabelFor("completed")).toBe("Completed");
+ expect(statusLabelFor("stopped")).toBe("Stopped");
+ });
+});
+
+describe("formatRunTime", () => {
+ it("formats an ISO timestamp as HH:MM:SS (UTC components)", () => {
+ // Uses local getHours/Minutes/Seconds; under UTC env (TZ=UTC) reads 14:30:05.
+ // We assert the SHAPE (3 colon-separated 2-digit groups) so it's TZ-stable.
+ expect(formatRunTime(ISO_AT)).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(formatRunTime(ISO_AT).split(":")).toHaveLength(3);
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(formatRunTime("not-a-date")).toBe("—");
+ expect(formatRunTime("")).toBe("—");
+ });
+});
+
+describe("relativeLabel", () => {
+ it("just now when within a minute", () => {
+ expect(relativeLabel(ISO_AT, NOW)).toBe("just now");
+ expect(relativeLabel(ISO_AT, NOW + 30_000)).toBe("just now");
+ });
+ it("Nm ago under an hour", () => {
+ expect(relativeLabel(ISO_AT, NOW + 5 * 60_000)).toBe("5m ago");
+ expect(relativeLabel(ISO_AT, NOW + 59 * 60_000)).toBe("59m ago");
+ });
+ it("Nh ago under a day", () => {
+ expect(relativeLabel(ISO_AT, NOW + 2 * 3_600_000)).toBe("2h ago");
+ });
+ it("absolute date+time past a day", () => {
+ const label = relativeLabel(ISO_AT, NOW + 26 * 3_600_000);
+ expect(label).toMatch(/^[A-Z][a-z]{2} \d+, \d{2}:\d{2}$/);
+ });
+ it("future timestamp → just now (clock skew tolerance)", () => {
+ expect(relativeLabel(ISO_AT, NOW - 10_000)).toBe("just now");
+ });
+ it("returns — for an unparseable timestamp", () => {
+ expect(relativeLabel("nope", NOW)).toBe("—");
+ });
+});
+
+describe("viewRun / viewRuns", () => {
+ it("running run: warning badge + busy + labels", () => {
+ const v = viewRun(run({ status: "running" }), NOW);
+ expect(v.badge).toBe("warning");
+ expect(v.busy).toBe(true);
+ expect(v.statusLabel).toBe("Running");
+ expect(v.id).toBe("run-1");
+ expect(v.conversationId).toBe("conv-1");
+ expect(v.timeLabel).toMatch(/^\d{2}:\d{2}:\d{2}$/);
+ expect(v.relativeLabel).toBe("just now");
+ });
+ it("completed run: success badge, not busy", () => {
+ expect(viewRun(run({ status: "completed" }), NOW).badge).toBe("success");
+ });
+ it("stopped run: neutral badge, not busy", () => {
+ expect(viewRun(run({ status: "stopped" }), NOW).badge).toBe("neutral");
+ });
+ it("viewRuns preserves order", () => {
+ const views = viewRuns([run({ id: "a" }), run({ id: "b" })], NOW);
+ expect(views.map((v) => v.id)).toEqual(["a", "b"]);
+ });
+});
+
+describe("config form", () => {
+ it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
+ const f = emptyForm();
+ expect(f.enabled).toBe(false);
+ // 30 min → 0h 30m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(30);
+ expect(f.reasoningEffort).toBe("high"); // DEFAULT_REASONING_EFFORT
+ expect(f.systemPrompt).toBe("");
+ expect(f.model).toBe("");
+ });
+
+ it("formFromConfig resolves null reasoningEffort to the default", () => {
+ const f = formFromConfig(config({ reasoningEffort: null }));
+ expect(f.reasoningEffort).toBe("high");
+ });
+
+ it("formFromConfig passes through a set reasoningEffort", () => {
+ const f = formFromConfig(config({ reasoningEffort: "max" }));
+ expect(f.reasoningEffort).toBe("max");
+ });
+
+ it("formFromConfig splits intervalMinutes into hours + minutes (0–59)", () => {
+ expect(formFromConfig(config({ intervalMinutes: 90 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 30,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 60 }))).toMatchObject({
+ intervalHours: 1,
+ intervalMinutes: 0,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 59 }))).toMatchObject({
+ intervalHours: 0,
+ intervalMinutes: 59,
+ });
+ expect(formFromConfig(config({ intervalMinutes: 1440 }))).toMatchObject({
+ intervalHours: 24,
+ intervalMinutes: 0,
+ });
+ });
+
+ it("formFromConfig coerces malformed fields safely", () => {
+ const f = formFromConfig(
+ config({
+ enabled: "yes" as unknown as boolean,
+ intervalMinutes: -5,
+ model: 42 as unknown as string,
+ systemPrompt: undefined as unknown as string,
+ }),
+ );
+ expect(f.enabled).toBe(false); // non-true → false
+ // -5 clamps to 1 → 0h 1m
+ expect(f.intervalHours).toBe(0);
+ expect(f.intervalMinutes).toBe(1);
+ expect(f.model).toBe(""); // non-string → ""
+ expect(f.systemPrompt).toBe(""); // undefined → ""
+ });
+
+ it("normalizeInterval clamps to 1–1440 and rounds", () => {
+ expect(normalizeInterval(0)).toBe(1);
+ expect(normalizeInterval(-10)).toBe(1);
+ expect(normalizeInterval(1.4)).toBe(1);
+ expect(normalizeInterval(15.6)).toBe(16);
+ expect(normalizeInterval(2000)).toBe(1440);
+ expect(normalizeInterval("30" as unknown as number)).toBe(30); // default on non-number
+ expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
+ });
+
+ it("splitInterval / joinInterval round-trip (and clamp)", () => {
+ expect(splitInterval(90)).toEqual({ hours: 1, minutes: 30 });
+ expect(splitInterval(0)).toEqual({ hours: 0, minutes: 1 }); // 0 → clamps to 1
+ expect(splitInterval(1440)).toEqual({ hours: 24, minutes: 0 });
+ expect(splitInterval(2000)).toEqual({ hours: 24, minutes: 0 }); // clamped
+ // join recomputes + clamps
+ expect(joinInterval(1, 30)).toBe(90);
+ expect(joinInterval(0, 0)).toBe(1); // 0 → clamps to 1
+ expect(joinInterval(25, 0)).toBe(1440); // 1500 → clamps to 1440
+ expect(joinInterval(-1, 30)).toBe(30); // negatives floored to 0
+ expect(joinInterval("x" as unknown as number, 15)).toBe(15); // non-finite → 0h
+ });
+
+ it("patchFromForm recombines hours+minutes into intervalMinutes + carries every field", () => {
+ const f = formFromConfig(config({ intervalMinutes: 2000 }));
+ // 2000 clamps to 1440 → 24h 0m in the form
+ expect(f.intervalHours).toBe(24);
+ expect(f.intervalMinutes).toBe(0);
+ const patch = patchFromForm(f);
+ expect(patch.intervalMinutes).toBe(1440);
+ expect(patch.enabled).toBe(false);
+ expect(patch.model).toBe("openai/gpt-4o");
+ expect(patch.reasoningEffort).toBe("high");
+ expect(patch.systemPrompt).toBe("be helpful");
+ expect(patch.taskPrompt).toBe("check status");
+ });
+
+ it("patchFromForm recombines an arbitrary hours/minutes edit", () => {
+ const f = formFromConfig(config({ intervalMinutes: 15 }));
+ f.intervalHours = 2;
+ f.intervalMinutes = 45;
+ expect(patchFromForm(f).intervalMinutes).toBe(165);
+ });
+
+ it("formDiffers is false for a form seeded from the config (no edits)", () => {
+ const c = config({ reasoningEffort: "medium" });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false);
+ });
+
+ it("formDiffers is true after an edit", () => {
+ const c = config();
+ const f = formFromConfig(c);
+ f.systemPrompt = "changed";
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers is true after an interval edit (hours or minutes)", () => {
+ const c = config({ intervalMinutes: 90 });
+ const f = formFromConfig(c);
+ f.intervalMinutes = 45; // 1h45m vs 1h30m
+ expect(formDiffers(f, c)).toBe(true);
+ });
+
+ it("formDiffers treats null config effort as the default (matches the resolved form)", () => {
+ const c = config({ reasoningEffort: null });
+ const f = formFromConfig(c);
+ expect(formDiffers(f, c)).toBe(false); // null resolves to "high" == form
+ });
+});
+
+describe("system-prompt inheritance (override ⇄ global default)", () => {
+ const DEFAULT = "You are a helpful assistant.";
+
+ it("effectiveSystemPrompt: override wins when non-empty, else the default", () => {
+ expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT);
+ });
+
+ it("isInheritingSystemPrompt: true iff the override is empty", () => {
+ expect(isInheritingSystemPrompt("")).toBe(true);
+ expect(isInheritingSystemPrompt("custom")).toBe(false);
+ });
+
+ it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => {
+ // matching the default → inherit (never duplicate the default into the config)
+ expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe("");
+ // empty edit → inherit
+ expect(persistedSystemPrompt("", DEFAULT)).toBe("");
+ });
+
+ it("persistedSystemPrompt: a distinct edit → the override verbatim", () => {
+ expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom");
+ expect(persistedSystemPrompt(`${DEFAULT}\nmore`, DEFAULT)).toBe(`${DEFAULT}\nmore`);
+ });
+
+ it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => {
+ // A heartbeat inheriting (override "") displays the default; with no edit,
+ // persisting yields inherit ("") — so the global default stays the source.
+ const override = "";
+ const displayed = effectiveSystemPrompt(override, DEFAULT);
+ expect(displayed).toBe(DEFAULT);
+ expect(persistedSystemPrompt(displayed, DEFAULT)).toBe("");
+ });
+
+ it("round-trip: override → reset to default → persist inherit (clears override)", () => {
+ // User had an override, clicks Reset (textarea ← default): persisting clears
+ // the override ("" → inherit) because the text now matches the default.
+ const afterReset = DEFAULT;
+ expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe("");
+ });
+});
+
+describe("effortOptions re-export", () => {
+ it("exposes the canonical ladder with the default marked", () => {
+ const opts = effortOptions();
+ const values = opts.map((o) => o.value) as readonly string[];
+ expect(values).toEqual(["low", "medium", "high", "xhigh", "max"]);
+ const def = opts.find((o) => o.value === "high");
+ expect(def?.label).toBe("high (default)");
+ });
+});
+
+describe("reasoningEffort type narrowing (sanity)", () => {
+ // Ensures the imported ladder stays the wire's canonical set — if the wire
+ // ladder changes, this test flags the drift alongside the chat feature.
+ it("the five canonical levels", () => {
+ const levels: readonly ReasoningEffort[] = ["low", "medium", "high", "xhigh", "max"];
+ expect(levels).toHaveLength(5);
+ });
+});
+
+describe("normalizeHeartbeatConfig", () => {
+ it("passes through a well-formed config", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ expect(c).toEqual({
+ enabled: true,
+ systemPrompt: "sys",
+ taskPrompt: "task",
+ intervalMinutes: 20,
+ model: "openai/gpt-4o",
+ reasoningEffort: "max",
+ });
+ });
+ it("coerces a malformed body safely (never throws, never undefined)", () => {
+ const c = normalizeHeartbeatConfig({
+ enabled: "yes",
+ intervalMinutes: -3,
+ reasoningEffort: "bogus",
+ });
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(1);
+ expect(c.reasoningEffort).toBeNull();
+ expect(c.systemPrompt).toBe("");
+ expect(c.taskPrompt).toBe("");
+ expect(c.model).toBe("");
+ });
+ it("accepts a null reasoningEffort", () => {
+ expect(normalizeHeartbeatConfig({ reasoningEffort: null }).reasoningEffort).toBeNull();
+ });
+ it("handles null / non-object input", () => {
+ const c = normalizeHeartbeatConfig(null);
+ expect(c.enabled).toBe(false);
+ expect(c.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ expect(c.model).toBe("");
+ });
+ it("clamps a huge interval", () => {
+ expect(normalizeHeartbeatConfig({ intervalMinutes: 99999 }).intervalMinutes).toBe(1440);
+ });
+});
+
+describe("normalizeHeartbeatRuns", () => {
+ it("maps a well-formed runs list", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "2026-06-25T10:00:00Z", status: "running" },
+ {
+ id: "r2",
+ conversationId: "c2",
+ triggeredAt: "2026-06-25T09:00:00Z",
+ status: "completed",
+ },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]).toMatchObject({ id: "r1", status: "running" });
+ expect(runs[1]).toMatchObject({ id: "r2", status: "completed" });
+ });
+ it("returns [] for malformed body", () => {
+ expect(normalizeHeartbeatRuns(null)).toEqual([]);
+ expect(normalizeHeartbeatRuns({})).toEqual([]);
+ expect(normalizeHeartbeatRuns({ runs: "nope" })).toEqual([]);
+ });
+ it("drops runs missing id/conversationId and defaults unknown status", () => {
+ const runs = normalizeHeartbeatRuns({
+ runs: [
+ { id: "r1", conversationId: "c1", triggeredAt: "x", status: "garbage" },
+ { id: "", conversationId: "c2", triggeredAt: "x", status: "completed" },
+ { id: "r3", conversationId: "", triggeredAt: "x", status: "running" },
+ { id: "r4", conversationId: "c4", triggeredAt: "x", status: "stopped" },
+ ],
+ });
+ expect(runs).toHaveLength(2);
+ expect(runs[0]?.status).toBe("completed"); // "garbage" → default
+ expect(runs[0]?.id).toBe("r1");
+ expect(runs[1]?.id).toBe("r4");
+ });
+});
+
+describe("next-run countdown", () => {
+ const ISO_AT = "2026-06-25T14:05:00Z"; // 5 min past the hour
+
+ describe("nextRunEpoch", () => {
+ it("parses an ISO timestamp to epoch-ms", () => {
+ expect(nextRunEpoch(ISO_AT)).toBe(Date.parse(ISO_AT));
+ });
+ it("returns null for unparseable / empty / non-string", () => {
+ expect(nextRunEpoch("not-a-date")).toBeNull();
+ expect(nextRunEpoch("")).toBeNull();
+ expect(nextRunEpoch(null)).toBeNull();
+ expect(nextRunEpoch(undefined)).toBeNull();
+ });
+ });
+
+ describe("formatCountdown", () => {
+ it("null → —", () => {
+ expect(formatCountdown(null)).toBe("—");
+ });
+ it("≤ 0 → due", () => {
+ expect(formatCountdown(0)).toBe("due");
+ expect(formatCountdown(-5000)).toBe("due");
+ });
+ it("seconds only (< 1m)", () => {
+ expect(formatCountdown(32_000)).toBe("32s");
+ expect(formatCountdown(1_000)).toBe("1s");
+ });
+ it("minutes + seconds (1m–1h)", () => {
+ expect(formatCountdown(4 * 60_000 + 32_000)).toBe("4m 32s");
+ expect(formatCountdown(59 * 60_000 + 5_000)).toBe("59m 05s");
+ });
+ it("hours + minutes (≥ 1h)", () => {
+ expect(formatCountdown(3_600_000 + 5 * 60_000)).toBe("1h 05m");
+ expect(formatCountdown(2 * 3_600_000 + 30 * 60_000)).toBe("2h 30m");
+ });
+ });
+
+ describe("approximateNextRunEpoch", () => {
+ const runs = (times: string[]): HeartbeatRun[] =>
+ times.map((t, i) => ({
+ id: `r${i}`,
+ conversationId: "c",
+ triggeredAt: t,
+ status: "completed",
+ }));
+
+ it("disabled → null", () => {
+ expect(approximateNextRunEpoch(runs([ISO_AT]), 15, false)).toBeNull();
+ });
+ it("no runs → null (no fabricated countdown)", () => {
+ expect(approximateNextRunEpoch([], 15, true)).toBeNull();
+ });
+ it("latest run + interval (minutes)", () => {
+ // latest is the max triggeredAt (runs need not be ordered)
+ const unordered = runs(["2026-06-25T13:00:00Z", "2026-06-25T13:50:00Z"]);
+ // 13:50 + 15 min = 14:05
+ expect(approximateNextRunEpoch(unordered, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("ignores unparseable triggeredAt values", () => {
+ const mixed = runs(["not-a-date", "2026-06-25T13:50:00Z"]);
+ expect(approximateNextRunEpoch(mixed, 15, true)).toBe(Date.parse("2026-06-25T14:05:00Z"));
+ });
+ it("all-unparseable → null", () => {
+ expect(approximateNextRunEpoch(runs(["nope", "also-nope"]), 15, true)).toBeNull();
+ });
+ });
+});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
new file mode 100644
index 0000000..e91febd
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -0,0 +1,410 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import {
+ DEFAULT_REASONING_EFFORT,
+ effectiveEffort,
+ effortOptions,
+} from "../../chat/reasoning-effort";
+import type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+} from "./types";
+
+/**
+ * Pure view-models for the heartbeat feature — zero DOM, zero effects, zero
+ * Svelte. Maps backend `HeartbeatConfig`/`HeartbeatRun` to display shapes
+ * (badges, labels, formatted times) and holds the config-form helpers.
+ *
+ * The reasoning-effort ladder + resolution are SERVER-owned and shared with the
+ * per-conversation knob, so they are REUSED from `features/chat/reasoning-effort`
+ * (a sanctioned cross-feature import through its public exports) rather than
+ * redefined — no drift.
+ */
+
+export type Badge = "success" | "warning" | "error" | "neutral";
+
+/** A run shaped for display in the scrolling runs list. */
+export interface HeartbeatRunView {
+ readonly id: string;
+ readonly conversationId: string;
+ readonly status: HeartbeatRunStatus;
+ readonly statusLabel: string;
+ readonly badge: Badge;
+ /** True while the run is in flight (show a spinner). */
+ readonly busy: boolean;
+ /** A short absolute clock label, e.g. "14:30:05". */
+ readonly timeLabel: string;
+ /** A relative label, e.g. "5m ago" / "just now". */
+ readonly relativeLabel: string;
+}
+
+const RUNNING_LABEL = "Running";
+const COMPLETED_LABEL = "Completed";
+const STOPPED_LABEL = "Stopped";
+
+/**
+ * Map a run's status to a display badge + busy flag. `running` → warning +
+ * spinner, `completed` → success, `stopped` → neutral. Mirrors the LSP/MCP
+ * status visual treatment.
+ */
+export function badgeForStatus(status: HeartbeatRunStatus): { badge: Badge; busy: boolean } {
+ switch (status) {
+ case "running":
+ return { badge: "warning", busy: true };
+ case "completed":
+ return { badge: "success", busy: false };
+ case "stopped":
+ return { badge: "neutral", busy: false };
+ }
+}
+
+export function statusLabelFor(status: HeartbeatRunStatus): string {
+ switch (status) {
+ case "running":
+ return RUNNING_LABEL;
+ case "completed":
+ return COMPLETED_LABEL;
+ case "stopped":
+ return STOPPED_LABEL;
+ }
+}
+
+/**
+ * Format an ISO timestamp as a short absolute clock label (HH:MM:SS) in the
+ * viewer's locale. Returns "—" for an unparseable timestamp so the UI never
+ * crashes on a malformed backend value. Pure (no `now` needed — an absolute
+ * clock label doesn't depend on the current time).
+ */
+export function formatRunTime(triggeredAt: string): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ return clockLabel(t);
+}
+
+/**
+ * A coarse relative label — "just now" (<1m), "Nm ago", "Nh ago", else the
+ * absolute date+time (so an old run reads "Jun 24, 14:30"). Pure via `now`.
+ */
+export function relativeLabel(triggeredAt: string, now: number = Date.now()): string {
+ const t = parseTime(triggeredAt);
+ if (t === null) return "—";
+ const deltaMs = now - t;
+ if (deltaMs < 0) return "just now";
+ const mins = Math.floor(deltaMs / 60000);
+ if (mins < 1) return "just now";
+ if (mins < 60) return `${mins}m ago`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h ago`;
+ return dateLabel(t);
+}
+
+/**
+ * Build a display view for a run. `now` is injectable for tests (defaults to
+ * `Date.now()`); the composition-root component passes nothing in production.
+ */
+export function viewRun(run: HeartbeatRun, now: number = Date.now()): HeartbeatRunView {
+ const { badge, busy } = badgeForStatus(run.status);
+ return {
+ id: run.id,
+ conversationId: run.conversationId,
+ status: run.status,
+ statusLabel: statusLabelFor(run.status),
+ badge,
+ busy,
+ timeLabel: formatRunTime(run.triggeredAt),
+ relativeLabel: relativeLabel(run.triggeredAt, now),
+ };
+}
+
+export function viewRuns(
+ runs: readonly HeartbeatRun[],
+ now: number = Date.now(),
+): readonly HeartbeatRunView[] {
+ return runs.map((r) => viewRun(r, now));
+}
+
+// ── Time formatting (pure: no `Date` mutation; injectable `now` for tests) ─────
+
+/** Parse an ISO timestamp to epoch ms, or null if unparseable. */
+function parseTime(iso: string): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/** `HH:MM:SS` in the viewer's locale (24h where the locale uses it). */
+function clockLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ const ss = String(d.getSeconds()).padStart(2, "0");
+ return `${hh}:${mm}:${ss}`;
+}
+
+/** A short absolute date+time label for an old run, e.g. "Jun 24, 14:30". */
+function dateLabel(epochMs: number): string {
+ const d = new Date(epochMs);
+ const month = d.toLocaleString(undefined, { month: "short" });
+ const day = d.getDate();
+ const hh = String(d.getHours()).padStart(2, "0");
+ const mm = String(d.getMinutes()).padStart(2, "0");
+ return `${month} ${day}, ${hh}:${mm}`;
+}
+
+// ── Next-run countdown (timer of when the next heartbeat fires) ───────────────
+//
+// The authoritative next-run time comes from the backend
+// (`GET /workspaces/:id/heartbeat/next-run` → `nextRunAt` ISO string); the FE
+// computes a live countdown from it + a 1s clock. When that endpoint is absent,
+// the FE falls back to an approximation (`approximateNextRunEpoch`) from the
+// latest run + the configured interval.
+
+/** Parse an ISO timestamp to epoch-ms, or null if unparseable. */
+export function nextRunEpoch(iso: string | null | undefined): number | null {
+ if (typeof iso !== "string" || iso.length === 0) return null;
+ const t = Date.parse(iso);
+ return Number.isNaN(t) ? null : t;
+}
+
+/**
+ * Format a remaining-ms delta as a short countdown: "4m 32s", "32s", "1h 05m",
+ * "due" (≤ 0), or "—" (unknown/null). Pure via the injected `remainingMs`.
+ */
+export function formatCountdown(remainingMs: number | null): string {
+ if (remainingMs === null) return "—";
+ if (remainingMs <= 0) return "due";
+ const totalSec = Math.floor(remainingMs / 1000);
+ const hours = Math.floor(totalSec / 3600);
+ const mins = Math.floor((totalSec % 3600) / 60);
+ const secs = totalSec % 60;
+ if (hours > 0) return `${hours}h ${String(mins).padStart(2, "0")}m`;
+ if (mins > 0) return `${mins}m ${String(secs).padStart(2, "0")}s`;
+ return `${secs}s`;
+}
+
+/**
+ * Approximate the next-run epoch-ms when the backend's `next-run` endpoint is
+ * unavailable: the LATEST run's `triggeredAt` + `intervalMinutes` (only when the
+ * heartbeat is enabled AND at least one run exists). Returns null otherwise (the
+ * FE then shows no countdown — never a fabricated one). The latest run is the
+ * max `triggeredAt` (runs need not be ordered). Pure (no `now` needed — the next
+ * run is latest + interval, independent of the current time).
+ */
+export function approximateNextRunEpoch(
+ runs: readonly HeartbeatRun[],
+ intervalMinutes: number,
+ enabled: boolean,
+): number | null {
+ if (!enabled) return null;
+ let latest: number | null = null;
+ for (const r of runs) {
+ const t = Date.parse(r.triggeredAt);
+ if (!Number.isNaN(t) && (latest === null || t > latest)) latest = t;
+ }
+ if (latest === null) return null;
+ return latest + intervalMinutes * 60_000;
+}
+
+// ── Config form ───────────────────────────────────────────────────────────────
+
+/**
+ * The editable form state for the config panel — a mutable mirror of a loaded
+ * `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to
+ * an effective level for the `<select>` (null ⇒ default `high`), exactly like
+ * the per-conversation selector.
+ *
+ * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the
+ * UI (two inputs), and recombined to a total-minutes value at the patch seam
+ * (`patchFromForm`); the backend stores a single `intervalMinutes`.
+ */
+export interface HeartbeatFormState {
+ enabled: boolean;
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalHours: number;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort;
+}
+
+/** The default interval (minutes) shown for an empty/unset config. */
+export const DEFAULT_INTERVAL_MINUTES = 30;
+
+/** Split a total-minutes value into { hours, minutes (0–59) }. Pure. */
+export function splitInterval(totalMinutes: number): { hours: number; minutes: number } {
+ const total = normalizeInterval(totalMinutes);
+ const hours = Math.floor(total / 60);
+ const minutes = total - hours * 60;
+ return { hours, minutes };
+}
+
+/** Recombine hours + minutes into a clamped total-minutes value. Pure. */
+export function joinInterval(hours: number, minutes: number): number {
+ const h = Number.isFinite(hours) ? Math.max(0, Math.floor(hours)) : 0;
+ const m = Number.isFinite(minutes) ? Math.max(0, Math.floor(minutes)) : 0;
+ return normalizeInterval(h * 60 + m);
+}
+
+/**
+ * Seed the editable form state from a loaded config, applying safe defaults for
+ * any malformed/absent backend field so the inputs are never `undefined`.
+ */
+export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return {
+ enabled: config.enabled === true,
+ systemPrompt: config.systemPrompt ?? "",
+ taskPrompt: config.taskPrompt ?? "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: typeof config.model === "string" ? config.model : "",
+ reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
+ };
+}
+
+/** An empty form (before the config loads). */
+export function emptyForm(): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
+ return {
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalHours: hours,
+ intervalMinutes: minutes,
+ model: "",
+ reasoningEffort: DEFAULT_REASONING_EFFORT,
+ };
+}
+
+/** Clamp a raw interval to a sane positive-minute range (1–1440 = 1 min–24 h). */
+export function normalizeInterval(value: unknown): number {
+ const n = typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_INTERVAL_MINUTES;
+ const int = Math.round(n);
+ if (int < 1) return 1;
+ if (int > 1440) return 1440;
+ return int;
+}
+
+/**
+ * The patch to PUT when persisting the form. The split hours+minutes are
+ * recombined into a single `intervalMinutes` (clamped); text fields are sent
+ * verbatim. `reasoningEffort` is always present (a resolved level) since the
+ * heartbeat has no per-run override — it persists the level.
+ */
+export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
+ return {
+ enabled: form.enabled,
+ systemPrompt: form.systemPrompt,
+ taskPrompt: form.taskPrompt,
+ intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
+ model: form.model,
+ reasoningEffort: form.reasoningEffort,
+ };
+}
+
+/** Whether the form differs from the loaded config (drives the Save button). */
+export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): boolean {
+ const { hours, minutes } = splitInterval(config.intervalMinutes);
+ return (
+ form.enabled !== config.enabled ||
+ form.systemPrompt !== (config.systemPrompt ?? "") ||
+ form.taskPrompt !== (config.taskPrompt ?? "") ||
+ form.intervalHours !== hours ||
+ form.intervalMinutes !== minutes ||
+ form.model !== (typeof config.model === "string" ? config.model : "") ||
+ form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
+ );
+}
+
+// ── System-prompt inheritance (heartbeat override ⇄ global default) ────────────
+//
+// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the
+// one every workspace conversation uses — there is no per-workspace system
+// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the
+// global default" (server-owned resolution: the backend resolves empty → global
+// at run time; see CR-HB-2). These pure helpers keep the override/inherit
+// semantics in ONE place so the editor + form agree.
+
+/**
+ * The prompt to DISPLAY: the heartbeat's override if it set one, else the global
+ * default. The editor pre-fills the textarea with this so the user can see (and
+ * tweak) what will run — but a pre-filled default is NOT an explicit edit.
+ */
+export function effectiveSystemPrompt(override: string, defaultPrompt: string): string {
+ return override !== "" ? override : defaultPrompt;
+}
+
+/** Whether the heartbeat is inheriting the global default (empty override). */
+export function isInheritingSystemPrompt(override: string): boolean {
+ return override === "";
+}
+
+/**
+ * The `systemPrompt` value to PERSIST for the given editable text: if the user's
+ * text matches the global default (or is empty), persist `""` to INHERIT (so a
+ * later change to the global default still flows through); otherwise persist the
+ * text verbatim as an override. This keeps "matching the default = inheriting it"
+ * — never duplicating the default into the heartbeat config.
+ */
+export function persistedSystemPrompt(editable: string, defaultPrompt: string): string {
+ if (editable === "" || editable === defaultPrompt) return "";
+ return editable;
+}
+
+// The reasoning-effort `<option>`s are reused verbatim from the per-conversation
+// selector (re-exported so the config panel imports a single source).
+export { effortOptions };
+
+// ── Network-seam normalization (pure; called by the composition root) ────────
+//
+// The heartbeat API is untyped JSON (not a transport-contract type), so the
+// store coerces each response defensively HERE (pure + tested) — a malformed/
+// partial backend value can never crash the renderer. Mirrors the inline
+// `Array.isArray(data.servers) ? … : []` guard the store does for LSP/MCP.
+
+/** Narrow an untrusted string to the run-status enum, defaulting to "completed". */
+function asRunStatus(value: unknown): HeartbeatRunStatus {
+ if (value === "running" || value === "completed" || value === "stopped") return value;
+ return "completed";
+}
+
+/** Coerce an untrusted `GET .../heartbeat/runs` body into a typed run list. */
+export function normalizeHeartbeatRuns(data: unknown): readonly HeartbeatRun[] {
+ if (!isRecord(data) || !Array.isArray(data.runs)) return [];
+ const runs = data.runs as readonly unknown[];
+ return runs
+ .filter((r): r is Record<string, unknown> => r !== null && typeof r === "object")
+ .map((r) => ({
+ id: typeof r.id === "string" ? r.id : "",
+ conversationId: typeof r.conversationId === "string" ? r.conversationId : "",
+ triggeredAt: typeof r.triggeredAt === "string" ? r.triggeredAt : "",
+ status: asRunStatus(r.status),
+ }))
+ .filter((r) => r.id !== "" && r.conversationId !== "");
+}
+
+/** Coerce an untrusted `GET`/`PUT .../heartbeat` body into a typed config. */
+export function normalizeHeartbeatConfig(data: unknown): HeartbeatConfig {
+ const d = isRecord(data) ? data : {};
+ const effort = d.reasoningEffort;
+ return {
+ enabled: d.enabled === true,
+ systemPrompt: typeof d.systemPrompt === "string" ? d.systemPrompt : "",
+ taskPrompt: typeof d.taskPrompt === "string" ? d.taskPrompt : "",
+ intervalMinutes: normalizeInterval(d.intervalMinutes),
+ model: typeof d.model === "string" ? d.model : "",
+ reasoningEffort:
+ effort === "low" ||
+ effort === "medium" ||
+ effort === "high" ||
+ effort === "xhigh" ||
+ effort === "max"
+ ? effort
+ : null,
+ };
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
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}
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..284b319
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.test.ts
@@ -0,0 +1,167 @@
+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,
+ 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..a4ed356
--- /dev/null
+++ b/src/features/heartbeat/ui/RunModal.svelte
@@ -0,0 +1,169 @@
+<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,
+ }: {
+ /** 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;
+ } = $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}
+ />
+ {/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>
diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts
index 661b341..50d9d21 100644
--- a/src/features/system-prompt/index.ts
+++ b/src/features/system-prompt/index.ts
@@ -7,7 +7,7 @@ export type {
SystemPromptVariablesResult,
VariableGroup,
} from "./logic/view-model";
-export { buildTag, groupVariables, insertTag } from "./logic/view-model";
+export { buildTag, groupVariables, insertTag, isDynamicVariable } from "./logic/view-model";
export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */