summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/heartbeat/index.ts39
-rw-r--r--src/features/heartbeat/logic/types.ts89
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts299
-rw-r--r--src/features/heartbeat/logic/view-model.ts294
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte421
-rw-r--r--src/features/heartbeat/ui/RunModal.svelte169
-rw-r--r--src/features/workspaces/ui/WorkspaceCard.test.ts2
7 files changed, 1312 insertions, 1 deletions
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts
new file mode 100644
index 0000000..00c3eeb
--- /dev/null
+++ b/src/features/heartbeat/index.ts
@@ -0,0 +1,39 @@
+export type {
+ HeartbeatConfig,
+ HeartbeatConfigPatch,
+ HeartbeatConfigResult,
+ HeartbeatRun,
+ HeartbeatRunStatus,
+ HeartbeatRunsResult,
+ HeartbeatStopResult,
+ LoadHeartbeatConfig,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+} from "./logic/types";
+export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-model";
+export {
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effortOptions,
+ emptyForm,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ relativeLabel,
+ statusLabelFor,
+ viewRun,
+ viewRuns,
+} from "./logic/view-model";
+export { default as HeartbeatView } from "./ui/HeartbeatView.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..b3871c9
--- /dev/null
+++ b/src/features/heartbeat/logic/types.ts
@@ -0,0 +1,89 @@
+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>;
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..fc43112
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -0,0 +1,299 @@
+import type { ReasoningEffort } from "@dispatch/transport-contract";
+import { describe, expect, it } from "vitest";
+import type { HeartbeatConfig, HeartbeatRun } from "./types";
+import {
+ badgeForStatus,
+ DEFAULT_INTERVAL_MINUTES,
+ effortOptions,
+ emptyForm,
+ formatRunTime,
+ formDiffers,
+ formFromConfig,
+ normalizeHeartbeatConfig,
+ normalizeHeartbeatRuns,
+ normalizeInterval,
+ patchFromForm,
+ relativeLabel,
+ 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, default effort)", () => {
+ const f = emptyForm();
+ expect(f.enabled).toBe(false);
+ expect(f.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ 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 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
+ expect(f.intervalMinutes).toBe(1); // clamped
+ 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("patchFromForm clamps interval + carries every field", () => {
+ const f = formFromConfig(config({ intervalMinutes: 2000 }));
+ 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("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 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("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");
+ });
+});
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
new file mode 100644
index 0000000..f5b9f96
--- /dev/null
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -0,0 +1,294 @@
+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}`;
+}
+
+// ── 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.
+ */
+export interface HeartbeatFormState {
+ enabled: boolean;
+ systemPrompt: string;
+ taskPrompt: string;
+ intervalMinutes: number;
+ model: string;
+ reasoningEffort: ReasoningEffort;
+}
+
+/** The default interval (minutes) shown for an empty/unset config. */
+export const DEFAULT_INTERVAL_MINUTES = 30;
+
+/**
+ * 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 {
+ return {
+ enabled: config.enabled === true,
+ systemPrompt: config.systemPrompt ?? "",
+ taskPrompt: config.taskPrompt ?? "",
+ intervalMinutes: normalizeInterval(config.intervalMinutes),
+ model: typeof config.model === "string" ? config.model : "",
+ reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
+ };
+}
+
+/** An empty form (before the config loads). */
+export function emptyForm(): HeartbeatFormState {
+ return {
+ enabled: false,
+ systemPrompt: "",
+ taskPrompt: "",
+ intervalMinutes: DEFAULT_INTERVAL_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. Only `intervalMinutes` is 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: normalizeInterval(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 {
+ return (
+ form.enabled !== config.enabled ||
+ form.systemPrompt !== (config.systemPrompt ?? "") ||
+ form.taskPrompt !== (config.taskPrompt ?? "") ||
+ form.intervalMinutes !== normalizeInterval(config.intervalMinutes) ||
+ form.model !== (typeof config.model === "string" ? config.model : "") ||
+ form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
+ );
+}
+
+// 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..7e0e7b7
--- /dev/null
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -0,0 +1,421 @@
+<script lang="ts">
+ import { untrack } from "svelte";
+ import type { ReasoningEffort } from "@dispatch/transport-contract";
+ import { isReasoningEffort } from "../../chat/reasoning-effort";
+ import {
+ badgeForStatus,
+ type Badge,
+ DEFAULT_INTERVAL_MINUTES,
+ emptyForm,
+ effortOptions,
+ formDiffers,
+ formFromConfig,
+ normalizeInterval,
+ patchFromForm,
+ viewRuns,
+ type HeartbeatFormState,
+ type HeartbeatRunView,
+ } from "../logic/view-model";
+ import type {
+ LoadHeartbeatConfig,
+ LoadHeartbeatRuns,
+ SaveHeartbeatConfig,
+ StopHeartbeatRun,
+ } from "../logic/types";
+
+ let {
+ models,
+ loadConfig,
+ saveConfig,
+ loadRuns,
+ stopRun,
+ onOpenRun,
+ }: {
+ /** The available model names (for the config's model dropdown). */
+ models: readonly string[];
+ loadConfig: LoadHeartbeatConfig;
+ saveConfig: SaveHeartbeatConfig;
+ loadRuns: LoadHeartbeatRuns;
+ stopRun: StopHeartbeatRun;
+ /** 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);
+
+ 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[]>([]);
+ let runsLoading = $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;
+
+ const RUN_POLL_MS = 4000;
+
+ async function refreshRuns(): Promise<void> {
+ runsLoading = true;
+ runsError = null;
+ const result = await loadRuns();
+ runsLoading = false;
+ if (result === null) return;
+ if (result.ok) {
+ runs = viewRuns(result.runs);
+ } 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 on mount, and poll runs while the view is alive so a
+ // running run's completion/stopped transition shows without a manual refresh.
+ $effect(() => {
+ untrack(() => {
+ void refreshConfig();
+ void refreshRuns();
+ });
+ pollHandle = setInterval(() => {
+ void refreshRuns();
+ }, 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;
+ });
+</script>
+
+<div class="flex flex-col gap-3">
+ <!-- Enable / status header -->
+ <section 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>
+ </section>
+
+ {#if configError}
+ <p class="text-xs text-error">{configError}</p>
+ {:else}
+ <!-- System prompt -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">System prompt</span>
+ <textarea
+ class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs"
+ placeholder="You are an autonomous agent…"
+ value={form.systemPrompt}
+ disabled={saving || configLoading}
+ oninput={(e) => (form = { ...form, systemPrompt: e.currentTarget.value })}
+ aria-label="Heartbeat system prompt"
+ ></textarea>
+ </section>
+
+ <!-- Task prompt -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Task prompt</span>
+ <textarea
+ class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs"
+ placeholder="Check the system status and report…"
+ value={form.taskPrompt}
+ disabled={saving || configLoading}
+ oninput={(e) => (form = { ...form, taskPrompt: e.currentTarget.value })}
+ aria-label="Heartbeat task prompt"
+ ></textarea>
+ </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 -->
+ <section class="flex flex-col gap-1">
+ <span class="text-xs font-semibold uppercase opacity-60">Interval (minutes)</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="number"
+ class="input input-bordered input-sm w-24"
+ min="1"
+ max="1440"
+ placeholder={String(DEFAULT_INTERVAL_MINUTES)}
+ value={form.intervalMinutes}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = {
+ ...form,
+ intervalMinutes: Number.isNaN(n) ? DEFAULT_INTERVAL_MINUTES : n,
+ };
+ }}
+ onchange={(e) => {
+ form = { ...form, intervalMinutes: normalizeInterval(form.intervalMinutes) };
+ e.currentTarget.value = String(form.intervalMinutes);
+ }}
+ aria-label="Heartbeat interval in minutes"
+ />
+ <span class="text-xs opacity-60">min 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"
+ disabled={runsLoading}
+ onclick={() => refreshRuns()}
+ aria-label="Refresh heartbeat runs"
+ >
+ {#if runsLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Refresh
+ {/if}
+ </button>
+ </div>
+
+ {#if runsError}
+ <p class="text-xs text-error">{runsError}</p>
+ {:else if runs.length === 0 && !runsLoading}
+ <p class="text-xs opacity-60">No runs yet. Enable the heartbeat to start the loop.</p>
+ {:else}
+ <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}
+ {/if}
+ </section>
+</div>
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/workspaces/ui/WorkspaceCard.test.ts b/src/features/workspaces/ui/WorkspaceCard.test.ts
index f6ea432..7de97d9 100644
--- a/src/features/workspaces/ui/WorkspaceCard.test.ts
+++ b/src/features/workspaces/ui/WorkspaceCard.test.ts
@@ -53,7 +53,7 @@ describe("WorkspaceCard", () => {
it("renders the title, slug, and an Open link", () => {
const store = fakeStore() as unknown as WorkspaceStore;
render(WorkspaceCard, {
- props: { ws: fakeEntry(), store, onNavigate, computers: [] },
+ props: { ws: fakeEntry(), store, onNavigate: vi.fn(), computers: [] },
});
expect(screen.getByText("My Workspace")).toBeInTheDocument();
expect(screen.getByText("/my-ws")).toBeInTheDocument();