summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 23:08:54 +0900
committerAdam Malczewski <[email protected]>2026-06-26 23:08:54 +0900
commitce1a4415158e6502efc068077c8495314d177cfe (patch)
tree7df2b691badca4cbd9e069a07015bdb249792c91 /src
parent1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9 (diff)
downloaddispatch-web-ce1a4415158e6502efc068077c8495314d177cfe.tar.gz
dispatch-web-ce1a4415158e6502efc068077c8495314d177cfe.zip
fix(heartbeat): PromptEditor save not persisting — flickers and reverts
Diffstat (limited to 'src')
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts2
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte41
-rw-r--r--src/features/heartbeat/ui/PromptEditor.test.ts167
3 files changed, 192 insertions, 18 deletions
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index 68f81c2..63bbf16 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -275,7 +275,7 @@ describe("system-prompt inheritance (override ⇄ global default)", () => {
it("persistedSystemPrompt: a distinct edit → the override verbatim", () => {
expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom");
- expect(persistedSystemPrompt(DEFAULT + "\nmore", DEFAULT)).toBe(DEFAULT + "\nmore");
+ expect(persistedSystemPrompt(`${DEFAULT}\nmore`, DEFAULT)).toBe(`${DEFAULT}\nmore`);
});
it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => {
diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte
index 0b76f5d..2320827 100644
--- a/src/features/heartbeat/ui/PromptEditor.svelte
+++ b/src/features/heartbeat/ui/PromptEditor.svelte
@@ -57,21 +57,14 @@
let system = $state(untrack(() => systemPrompt));
let task = $state(untrack(() => taskPrompt));
- // The raw persisted override at open (for the "inheriting" indicator + the
- // reset baseline). Empty = the heartbeat is inheriting the global default.
- const rawLoadedSystem = untrack(() => systemPrompt);
+ // 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));
- /** Once the default loads, pre-fill an inheriting system prompt with it
- * (only if the user hasn't edited away from the raw override yet). */
- let defaultApplied = $state(false);
- $effect(() => {
- if (!defaultApplied && defaultPrompt !== "" && isInheritingSystemPrompt(rawLoadedSystem)) {
- system = defaultPrompt;
- defaultApplied = true;
- }
- });
-
let variables = $state<readonly SystemPromptVariable[]>([]);
let varsLoading = $state(false);
let varsError = $state<string | null>(null);
@@ -88,10 +81,11 @@
let taskEl = $state<HTMLTextAreaElement | null>(null);
const groups = $derived(groupVariables(variables));
- /** The baseline system text to diff against: the effective prompt at open
- * (override, or the default when inheriting) — so a pre-filled default does
- * NOT register as an unsaved change. */
- const systemBaseline = $derived(effectiveSystemPrompt(rawLoadedSystem, defaultPrompt));
+ /** 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 !== "");
@@ -118,6 +112,14 @@
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.
@@ -135,6 +137,11 @@
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);
diff --git a/src/features/heartbeat/ui/PromptEditor.test.ts b/src/features/heartbeat/ui/PromptEditor.test.ts
new file mode 100644
index 0000000..4b5c960
--- /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();
+ });
+});