diff options
| author | Adam Malczewski <[email protected]> | 2026-06-27 01:13:28 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-27 01:13:31 +0900 |
| commit | 2fa03f8d7410c2b8d6be8e10ad088863e83d7177 (patch) | |
| tree | 94e1923180ae38d571d34b578afecb0a18913c24 /src/features/heartbeat | |
| parent | 80f99665034a0e510300793205c162fc7a46769f (diff) | |
| parent | 08b12478636f4a5c86a1f3c40a843f2906b7c82f (diff) | |
| download | dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.tar.gz dispatch-web-2fa03f8d7410c2b8d6be8e10ad088863e83d7177.zip | |
Merge branch 'dev' into feature/heartbeat
# Conflicts:
# src/app/App.svelte
# src/app/store.svelte.ts
# src/app/store.test.ts
# src/features/workspaces/ui/WorkspaceCard.test.ts
Diffstat (limited to 'src/features/heartbeat')
| -rw-r--r-- | src/features/heartbeat/index.ts | 76 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/types.ts | 70 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 842 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 380 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.test.ts | 284 |
5 files changed, 826 insertions, 826 deletions
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts index fd1cf8d..cc438f1 100644 --- a/src/features/heartbeat/index.ts +++ b/src/features/heartbeat/index.ts @@ -1,43 +1,43 @@ export type { - HeartbeatConfig, - HeartbeatConfigPatch, - HeartbeatConfigResult, - HeartbeatNextRunResult, - HeartbeatRun, - HeartbeatRunStatus, - HeartbeatRunsResult, - HeartbeatStopResult, - LoadHeartbeatConfig, - LoadHeartbeatNextRun, - LoadHeartbeatRuns, - SaveHeartbeatConfig, - StopHeartbeatRun, + 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, + 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"; @@ -45,6 +45,6 @@ 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", + 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 index ede22a6..3d3d525 100644 --- a/src/features/heartbeat/logic/types.ts +++ b/src/features/heartbeat/logic/types.ts @@ -24,20 +24,20 @@ 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; + /** 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; } /** @@ -45,22 +45,22 @@ export interface HeartbeatConfig { * 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; + 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; + 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 @@ -68,22 +68,22 @@ export interface HeartbeatRun { /** 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 }; + | { 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 }; + | { 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 }; + | { readonly ok: true } + | { readonly ok: false; readonly error: string }; export type LoadHeartbeatConfig = () => Promise<HeartbeatConfigResult | null>; export type SaveHeartbeatConfig = ( - patch: HeartbeatConfigPatch, + patch: HeartbeatConfigPatch, ) => Promise<HeartbeatConfigResult | null>; export type LoadHeartbeatRuns = () => Promise<HeartbeatRunsResult | null>; export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | null>; @@ -97,7 +97,7 @@ export type StopHeartbeatRun = (runId: string) => Promise<HeartbeatStopResult | * approximation from the runs + config (see `approximateNextRunEpoch`). */ export type HeartbeatNextRunResult = - | { readonly ok: true; readonly nextRunAt: string | null } - | { readonly ok: false; readonly error: string }; + | { 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 index d237817..aca0aa6 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -2,469 +2,469 @@ 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, + 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, + 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, + 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 }); - }); + 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"); - }); + 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("—"); - }); + 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("—"); - }); + 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"]); - }); + 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 - }); + 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(""); - }); + 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)"); - }); + 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); - }); + // 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); - }); + 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"); - }); + 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(); - }); - }); + 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 index 93c0e55..e91febd 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -1,14 +1,14 @@ import type { ReasoningEffort } from "@dispatch/transport-contract"; import { - DEFAULT_REASONING_EFFORT, - effectiveEffort, - effortOptions, + DEFAULT_REASONING_EFFORT, + effectiveEffort, + effortOptions, } from "../../chat/reasoning-effort"; import type { - HeartbeatConfig, - HeartbeatConfigPatch, - HeartbeatRun, - HeartbeatRunStatus, + HeartbeatConfig, + HeartbeatConfigPatch, + HeartbeatRun, + HeartbeatRunStatus, } from "./types"; /** @@ -26,17 +26,17 @@ 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; + 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"; @@ -49,25 +49,25 @@ const STOPPED_LABEL = "Stopped"; * 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 }; - } + 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; - } + switch (status) { + case "running": + return RUNNING_LABEL; + case "completed": + return COMPLETED_LABEL; + case "stopped": + return STOPPED_LABEL; + } } /** @@ -77,9 +77,9 @@ export function statusLabelFor(status: HeartbeatRunStatus): string { * 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); + const t = parseTime(triggeredAt); + if (t === null) return "—"; + return clockLabel(t); } /** @@ -87,16 +87,16 @@ export function formatRunTime(triggeredAt: string): string { * 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); + 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); } /** @@ -104,52 +104,52 @@ export function relativeLabel(triggeredAt: string, now: number = Date.now()): st * `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), - }; + 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(), + runs: readonly HeartbeatRun[], + now: number = Date.now(), ): readonly HeartbeatRunView[] { - return runs.map((r) => viewRun(r, now)); + 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; + 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}`; + 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}`; + 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) ─────────────── @@ -162,9 +162,9 @@ function dateLabel(epochMs: number): string { /** 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; + if (typeof iso !== "string" || iso.length === 0) return null; + const t = Date.parse(iso); + return Number.isNaN(t) ? null : t; } /** @@ -172,15 +172,15 @@ export function nextRunEpoch(iso: string | null | undefined): number | null { * "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`; + 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`; } /** @@ -192,18 +192,18 @@ export function formatCountdown(remainingMs: number | null): string { * run is latest + interval, independent of the current time). */ export function approximateNextRunEpoch( - runs: readonly HeartbeatRun[], - intervalMinutes: number, - enabled: boolean, + 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; + 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 ─────────────────────────────────────────────────────────────── @@ -219,13 +219,13 @@ export function approximateNextRunEpoch( * (`patchFromForm`); the backend stores a single `intervalMinutes`. */ export interface HeartbeatFormState { - enabled: boolean; - systemPrompt: string; - taskPrompt: string; - intervalHours: number; - intervalMinutes: number; - model: string; - reasoningEffort: ReasoningEffort; + enabled: boolean; + systemPrompt: string; + taskPrompt: string; + intervalHours: number; + intervalMinutes: number; + model: string; + reasoningEffort: ReasoningEffort; } /** The default interval (minutes) shown for an empty/unset config. */ @@ -233,17 +233,17 @@ 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 }; + 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); + 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); } /** @@ -251,39 +251,39 @@ export function joinInterval(hours: number, minutes: number): number { * 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), - }; + 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, - }; + 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; + 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; } /** @@ -293,28 +293,28 @@ export function normalizeInterval(value: unknown): number { * 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, - }; + 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) - ); + 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) ──────────── @@ -332,12 +332,12 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): * 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; + return override !== "" ? override : defaultPrompt; } /** Whether the heartbeat is inheriting the global default (empty override). */ export function isInheritingSystemPrompt(override: string): boolean { - return override === ""; + return override === ""; } /** @@ -348,8 +348,8 @@ export function isInheritingSystemPrompt(override: string): boolean { * — never duplicating the default into the heartbeat config. */ export function persistedSystemPrompt(editable: string, defaultPrompt: string): string { - if (editable === "" || editable === defaultPrompt) return ""; - return editable; + if (editable === "" || editable === defaultPrompt) return ""; + return editable; } // The reasoning-effort `<option>`s are reused verbatim from the per-conversation @@ -365,46 +365,46 @@ export { effortOptions }; /** 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"; + 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 !== ""); + 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, - }; + 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"; + return value !== null && typeof value === "object"; } diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts index 4b5c960..284b319 100644 --- a/src/features/heartbeat/ui/PromptEditor.test.ts +++ b/src/features/heartbeat/ui/PromptEditor.test.ts @@ -2,166 +2,166 @@ 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, + 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); + 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); + 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; + 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 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, + 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(); - }); + 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(); + }); }); |
