diff options
Diffstat (limited to 'src/features/heartbeat')
| -rw-r--r-- | src/features/heartbeat/index.ts | 50 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/types.ts | 103 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 470 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 410 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 536 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 412 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.test.ts | 167 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/RunModal.svelte | 176 |
8 files changed, 2324 insertions, 0 deletions
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..92068ae --- /dev/null +++ b/src/features/heartbeat/ui/RunModal.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import { tick } from "svelte"; + import { ChatView } from "../../chat"; + import type { ChatStore } from "../../chat"; + import type { HeartbeatRunView } from "../logic/view-model"; + import type { StopHeartbeatRun } from "../logic/types"; + + let { + run, + openChat, + closeChat, + stopRun, + onClose, + apiBaseUrl = "", + }: { + /** The run to display (its conversation's chat is shown live). */ + run: HeartbeatRunView; + /** + * Open a live watch on a conversation (the store's `watchConversation`): + * returns a {@link ChatStore} subscribed to the conversation's turn stream + * + history loaded. The modal owns the watch lifecycle — calls + * `closeChat` on unmount. + */ + openChat: (conversationId: string) => ChatStore; + /** Dispose + unsubscribe the watch opened by `openChat`. */ + closeChat: (conversationId: string) => void; + /** Stop the heartbeat run (`POST .../runs/:runId/stop`). */ + stopRun: StopHeartbeatRun; + onClose: () => void; + /** + * The HTTP API base URL, to resolve persisted image chunk URLs + * (`/images/…`) in the run's transcript. Defaults to "" (root-relative). + */ + apiBaseUrl?: string; + } = $props(); + + // Open the live watch ONCE on mount (the modal is keyed per run.id, so a run + // switch remounts it). `untrack` avoids re-running if the prop fn identity + // changes — `run.conversationId` is the real dependency, captured once here. + let chat = $state<ChatStore | null>(null); + $effect(() => { + chat = openChat(run.conversationId); + return () => closeChat(run.conversationId); + }); + + // Live scroll: keep the transcript pinned to the bottom while it streams + // (unless the reader has scrolled up — then we don't fight them). + let scrollEl = $state<HTMLDivElement | undefined>(); + let contentEl = $state<HTMLDivElement | undefined>(); + let pinned = $state(true); + + function onScroll() { + const el = scrollEl; + if (el === undefined) return; + pinned = el.scrollHeight - el.scrollTop - el.clientHeight < 40; + } + + // Follow the bottom on new content while pinned. Reads `chunks.length` so the + // effect re-runs on every streamed append. + const chunkCount = $derived(chat?.chunks.length ?? 0); + $effect(() => { + void chunkCount; + if (!pinned) return; + void tick().then(() => { + const el = scrollEl; + if (el !== undefined) el.scrollTop = el.scrollHeight; + }); + }); + + // Stop state. + let stopping = $state(false); + let stopError = $state<string | null>(null); + + async function handleStop() { + if (stopping) return; + stopping = true; + stopError = null; + const result = await stopRun(run.id); + stopping = false; + if (result === null) return; + if (!result.ok) stopError = result.error; + } + + // The live "running" signal: the chat store's `generating` reflects the + // actual event stream (turn-start…turn-sealed). True while a turn streams — + // that is when a Stop is meaningful. Falls back to the run's status snapshot + // before the stream attaches. + const live = $derived(chat?.generating ?? run.busy); + + function handleKeydown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } +</script> + +<svelte:window onkeydown={handleKeydown} /> + +<!-- Fullscreen overlay. --> +<div class="fixed inset-0 z-50 flex flex-col bg-base-100"> + <!-- Header --> + <header class="flex items-center justify-between gap-2 border-b border-base-300 px-4 py-2"> + <div class="flex min-w-0 items-center gap-2"> + <button + type="button" + class="btn btn-ghost btn-sm" + onclick={onClose} + aria-label="Close run chat" + > + ✕ + </button> + <span class="truncate font-mono text-xs opacity-70" title="Run id">{run.id}</span> + {#if live} + <span class="badge badge-sm badge-warning gap-1"> + <span class="loading loading-spinner loading-xs"></span> + Running + </span> + {:else} + <span class="badge badge-sm badge-ghost">{run.statusLabel}</span> + {/if} + </div> + <div class="flex items-center gap-2"> + {#if stopError} + <span class="text-xs text-error">{stopError}</span> + {/if} + {#if live} + <button + type="button" + class="btn btn-sm btn-error btn-outline" + disabled={stopping} + onclick={handleStop} + > + {#if stopping} + <span class="loading loading-spinner loading-xs"></span> + Stopping… + {:else} + Stop + {/if} + </button> + {/if} + </div> + </header> + + <!-- Transcript --> + <div class="relative min-h-0 flex-1"> + <div bind:this={scrollEl} class="h-full overflow-y-auto" onscroll={onScroll}> + <div bind:this={contentEl} class="p-4"> + {#if chat === null} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else if chat.chunks.length === 0 && chat.pendingSync} + <div class="flex h-full items-center justify-center"> + <span class="loading loading-spinner loading-md"></span> + </div> + {:else} + <ChatView + chunks={chat.chunks} + turnMetrics={chat.turnMetrics} + hasEarlier={chat.hasEarlier} + onShowEarlier={chat.showEarlier} + thinkingKeyBase={chat.thinkingKeyBase} + providerRetry={chat.providerRetry} + apiBaseUrl={apiBaseUrl} + /> + {/if} + </div> + </div> + {#if chat !== null && chat.chunks.length === 0 && !chat.pendingSync} + <div + class="pointer-events-none absolute inset-0 flex items-center justify-center" + aria-hidden="true" + > + <span class="select-none text-2xl font-bold opacity-10">No messages</span> + </div> + {/if} + </div> +</div> |
