diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 23:08:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 23:08:54 +0900 |
| commit | ce1a4415158e6502efc068077c8495314d177cfe (patch) | |
| tree | 7df2b691badca4cbd9e069a07015bdb249792c91 /src | |
| parent | 1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9 (diff) | |
| download | dispatch-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.ts | 2 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 41 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.test.ts | 167 |
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(); + }); +}); |
