diff options
| author | Adam Malczewski <[email protected]> | 2026-06-26 21:15:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-26 21:15:08 +0900 |
| commit | 1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9 (patch) | |
| tree | 5856ba0553a3d625f246e930b7f8f23c88e589e3 /src | |
| parent | a8c9be77cf5dd6249d8ea531c4ce79d823a6f600 (diff) | |
| download | dispatch-web-1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9.tar.gz dispatch-web-1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9.zip | |
feat(heartbeat): default system prompt to workspace default + reset button
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/App.svelte | 1 | ||||
| -rw-r--r-- | src/features/heartbeat/index.ts | 3 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.test.ts | 45 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 35 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 13 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 122 |
6 files changed, 201 insertions, 18 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index f9fecc7..7ad8733 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -681,6 +681,7 @@ loadRuns={loadHeartbeatRuns} stopRun={stopHeartbeatRun} loadVariables={loadSystemPromptVariablesPrompt} + loadDefaultPrompt={loadSystemPromptPrompt} onOpenRun={(run) => (heartbeatRun = run)} /> {/if} diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts index a54a448..0789ea6 100644 --- a/src/features/heartbeat/index.ts +++ b/src/features/heartbeat/index.ts @@ -15,16 +15,19 @@ export type { Badge, HeartbeatFormState, HeartbeatRunView } from "./logic/view-m export { badgeForStatus, DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, effortOptions, emptyForm, formatRunTime, formDiffers, formFromConfig, + isInheritingSystemPrompt, joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, + persistedSystemPrompt, relativeLabel, splitInterval, statusLabelFor, diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts index fe97496..68f81c2 100644 --- a/src/features/heartbeat/logic/view-model.test.ts +++ b/src/features/heartbeat/logic/view-model.test.ts @@ -4,16 +4,19 @@ import type { HeartbeatConfig, HeartbeatRun } from "./types"; import { badgeForStatus, DEFAULT_INTERVAL_MINUTES, + effectiveSystemPrompt, effortOptions, emptyForm, formatRunTime, formDiffers, formFromConfig, + isInheritingSystemPrompt, joinInterval, normalizeHeartbeatConfig, normalizeHeartbeatRuns, normalizeInterval, patchFromForm, + persistedSystemPrompt, relativeLabel, splitInterval, statusLabelFor, @@ -250,6 +253,48 @@ describe("config form", () => { }); }); +describe("system-prompt inheritance (override ⇄ global default)", () => { + const DEFAULT = "You are a helpful assistant."; + + it("effectiveSystemPrompt: override wins when non-empty, else the default", () => { + expect(effectiveSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(effectiveSystemPrompt("", DEFAULT)).toBe(DEFAULT); + }); + + it("isInheritingSystemPrompt: true iff the override is empty", () => { + expect(isInheritingSystemPrompt("")).toBe(true); + expect(isInheritingSystemPrompt("custom")).toBe(false); + }); + + it('persistedSystemPrompt: empty or matching-the-default → inherit ("")', () => { + // matching the default → inherit (never duplicate the default into the config) + expect(persistedSystemPrompt(DEFAULT, DEFAULT)).toBe(""); + // empty edit → inherit + expect(persistedSystemPrompt("", DEFAULT)).toBe(""); + }); + + it("persistedSystemPrompt: a distinct edit → the override verbatim", () => { + expect(persistedSystemPrompt("custom", DEFAULT)).toBe("custom"); + expect(persistedSystemPrompt(DEFAULT + "\nmore", DEFAULT)).toBe(DEFAULT + "\nmore"); + }); + + it("round-trip: inherit → display default → reset (no edit) → persist inherit", () => { + // A heartbeat inheriting (override "") displays the default; with no edit, + // persisting yields inherit ("") — so the global default stays the source. + const override = ""; + const displayed = effectiveSystemPrompt(override, DEFAULT); + expect(displayed).toBe(DEFAULT); + expect(persistedSystemPrompt(displayed, DEFAULT)).toBe(""); + }); + + it("round-trip: override → reset to default → persist inherit (clears override)", () => { + // User had an override, clicks Reset (textarea ← default): persisting clears + // the override ("" → inherit) because the text now matches the default. + const afterReset = DEFAULT; + expect(persistedSystemPrompt(afterReset, DEFAULT)).toBe(""); + }); +}); + describe("effortOptions re-export", () => { it("exposes the canonical ladder with the default marked", () => { const opts = effortOptions(); diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts index b79fff6..9f25eb9 100644 --- a/src/features/heartbeat/logic/view-model.ts +++ b/src/features/heartbeat/logic/view-model.ts @@ -263,6 +263,41 @@ export function formDiffers(form: HeartbeatFormState, config: HeartbeatConfig): ); } +// ── System-prompt inheritance (heartbeat override ⇄ global default) ──────────── +// +// The heartbeat's `systemPrompt` is an OVERRIDE of the global system prompt (the +// one every workspace conversation uses — there is no per-workspace system +// prompt; `GET /system-prompt` is global). An EMPTY override means "inherit the +// global default" (server-owned resolution: the backend resolves empty → global +// at run time; see CR-HB-2). These pure helpers keep the override/inherit +// semantics in ONE place so the editor + form agree. + +/** + * The prompt to DISPLAY: the heartbeat's override if it set one, else the global + * default. The editor pre-fills the textarea with this so the user can see (and + * tweak) what will run — but a pre-filled default is NOT an explicit edit. + */ +export function effectiveSystemPrompt(override: string, defaultPrompt: string): string { + return override !== "" ? override : defaultPrompt; +} + +/** Whether the heartbeat is inheriting the global default (empty override). */ +export function isInheritingSystemPrompt(override: string): boolean { + return override === ""; +} + +/** + * The `systemPrompt` value to PERSIST for the given editable text: if the user's + * text matches the global default (or is empty), persist `""` to INHERIT (so a + * later change to the global default still flows through); otherwise persist the + * text verbatim as an override. This keeps "matching the default = inheriting it" + * — never duplicating the default into the heartbeat config. + */ +export function persistedSystemPrompt(editable: string, defaultPrompt: string): string { + if (editable === "" || editable === defaultPrompt) return ""; + return editable; +} + // The reasoning-effort `<option>`s are reused verbatim from the per-conversation // selector (re-exported so the config panel imports a single source). export { effortOptions }; diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte index ba61759..d584cb3 100644 --- a/src/features/heartbeat/ui/HeartbeatView.svelte +++ b/src/features/heartbeat/ui/HeartbeatView.svelte @@ -20,7 +20,10 @@ SaveHeartbeatConfig, StopHeartbeatRun, } from "../logic/types"; - import type { LoadSystemPromptVariables } from "../../system-prompt"; + import type { + LoadSystemPrompt, + LoadSystemPromptVariables, + } from "../../system-prompt"; import PromptEditor from "./PromptEditor.svelte"; let { @@ -30,6 +33,7 @@ loadRuns, stopRun, loadVariables, + loadDefaultPrompt, onOpenRun, }: { /** The available model names (for the config's model dropdown). */ @@ -40,6 +44,9 @@ stopRun: StopHeartbeatRun; /** Load the available system-prompt variables (palette in the prompt editor). */ loadVariables: LoadSystemPromptVariables; + /** Load the global system prompt — the default the heartbeat inherits when + * its `systemPrompt` is empty (the workspace's regular prompt). */ + loadDefaultPrompt: LoadSystemPrompt; /** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */ onOpenRun: (run: HeartbeatRunView) => void; } = $props(); @@ -435,10 +442,12 @@ systemPrompt={form.systemPrompt} taskPrompt={form.taskPrompt} {loadVariables} + {loadDefaultPrompt} {saveConfig} onSaved={(systemPrompt, taskPrompt) => { // Sync the form + the diff baseline so the main Save button + formDiffers - // stay accurate (the editor persisted the prompts already). + // stay accurate (the editor persisted the prompts already). `systemPrompt` + // may be "" (inherit) — the form stores the raw override. form = { ...form, systemPrompt, taskPrompt }; loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt }; justSaved = true; diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte index 867c32a..0b76f5d 100644 --- a/src/features/heartbeat/ui/PromptEditor.svelte +++ b/src/features/heartbeat/ui/PromptEditor.svelte @@ -6,45 +6,76 @@ groupVariables, insertTag, isDynamicVariable, + type LoadSystemPrompt, type LoadSystemPromptVariables, } from "../../system-prompt"; import type { SaveHeartbeatConfig } from "../logic/types"; + import { + effectiveSystemPrompt, + isInheritingSystemPrompt, + persistedSystemPrompt, + } from "../logic/view-model"; import { portal } from "../../../adapters/portal"; let { systemPrompt, taskPrompt, loadVariables, + loadDefaultPrompt, saveConfig, onSaved, onClose, }: { - /** The current system prompt (seeded from the loaded config). */ + /** + * The heartbeat's persisted system prompt (raw override). Empty = inherit + * the global system prompt (the workspace's regular prompt). + */ systemPrompt: string; /** The current task prompt (seeded from the loaded config). */ taskPrompt: string; /** Load the available variables (`GET /system-prompt/variables`). */ loadVariables: LoadSystemPromptVariables; + /** Load the GLOBAL system prompt (`GET /system-prompt`) — the default the + * heartbeat inherits when its `systemPrompt` is empty. */ + loadDefaultPrompt: LoadSystemPrompt; /** Persist both prompts via a partial heartbeat config PUT. */ saveConfig: SaveHeartbeatConfig; - /** Called after a successful save with the persisted prompts, so the parent - * can sync its form (the editor edits local copies). */ + /** Called after a successful save with the RAW persisted prompts (system + * may be "" = inherit), so the parent can sync its form. */ onSaved: (systemPrompt: string, taskPrompt: string) => void; onClose: () => void; } = $props(); - // Local editable copies (the modal edits in isolation; Save commits both). - // Seeded ONCE from the props via `untrack` — the modal is re-mounted per open - // (keyed by the parent), so it captures the initial prompts, not a live view. + // The global default system prompt (loaded async on open). Empty until loaded + // (or when no global prompt is configured) — the editor degrades gracefully. + let defaultPrompt = $state(""); + + // The editable system text. Pre-filled with the EFFECTIVE prompt — the + // heartbeat's override, or the global default when inheriting (so the user + // can see + tweak what will run). A pre-filled default is NOT an explicit + // edit (see `hasChanges`). let system = $state(untrack(() => systemPrompt)); let task = $state(untrack(() => taskPrompt)); - /** Snapshots at open, to diff against (drives Save + Reset). */ - let loadedSystem = $state(untrack(() => systemPrompt)); + + // 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); 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); + let defaultLoading = $state(false); let saving = $state(false); let saveError = $state<string | null>(null); @@ -57,7 +88,13 @@ let taskEl = $state<HTMLTextAreaElement | null>(null); const groups = $derived(groupVariables(variables)); - const hasChanges = $derived(system !== loadedSystem || task !== loadedTask); + /** 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)); + const hasChanges = $derived(system !== systemBaseline || task !== loadedTask); + /** Whether the current text matches the default (i.e. saving would inherit). */ + const inheriting = $derived(system === defaultPrompt && defaultPrompt !== ""); async function loadVars(): Promise<void> { untrack(() => { @@ -73,31 +110,56 @@ } } + async function loadDefault(): Promise<void> { + untrack(() => { + defaultLoading = true; + }); + const result = await loadDefaultPrompt(); + defaultLoading = false; + if (result.ok) { + defaultPrompt = result.template; + } + // A failed default load is non-fatal: the editor still works with the + // raw override; only the "inherit" affordance is unavailable. + } + async function save(): Promise<void> { if (saving || !hasChanges) return; saving = true; saveError = null; justSaved = false; - const result = await saveConfig({ systemPrompt: system, taskPrompt: task }); + // Persist the system prompt via the inheritance helper: matching the + // default (or empty) → "" (inherit); otherwise the override verbatim. + const systemToPersist = persistedSystemPrompt(system, defaultPrompt); + const result = await saveConfig({ systemPrompt: systemToPersist, taskPrompt: task }); saving = false; if (result === null) return; if (result.ok) { - loadedSystem = system; loadedTask = task; justSaved = true; - onSaved(system, task); + onSaved(systemToPersist, task); } else { saveError = result.error; } } + /** Revert ALL edits to the open-time state (system effective prompt + task). */ function reset(): void { - system = loadedSystem; + system = systemBaseline; task = loadedTask; saveError = null; justSaved = false; } + /** Reset ONLY the system prompt to the global default (clears any override → + * inherit on save). No-op until the default has loaded. */ + function resetSystemToDefault(): void { + if (defaultPrompt === "") return; + system = defaultPrompt; + saveError = null; + justSaved = false; + } + /** * Insert a variable tag into the ACTIVE textarea at its cursor. The active * field is tracked via focus handlers; insertion uses that field's element + @@ -134,9 +196,10 @@ if (e.key === "Escape") onClose(); } - // Load the variable palette once on open. + // Load the variable palette + the global default once on open. $effect(() => { void loadVars(); + void loadDefault(); }); </script> @@ -187,16 +250,43 @@ <!-- Left: two text editors (system top, task bottom) --> <div class="flex w-1/2 min-w-0 flex-col gap-2 border-r border-base-300 p-4"> <div class="flex min-h-0 flex-1 flex-col gap-1"> - <span class="shrink-0 text-xs font-semibold uppercase opacity-60">System prompt</span> + <div class="flex shrink-0 items-center justify-between gap-2"> + <div class="flex items-center gap-2"> + <span class="text-xs font-semibold uppercase opacity-60">System prompt</span> + {#if defaultLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else if inheriting} + <span class="badge badge-ghost badge-sm font-normal">Inheriting workspace default</span> + {/if} + </div> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={defaultPrompt === "" || saving} + onclick={resetSystemToDefault} + title="Reset the system prompt to the workspace default (inherit)" + > + Reset to default + </button> + </div> <textarea bind:this={systemEl} bind:value={system} onfocus={() => (activeField = "system")} class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs" - placeholder="You are an autonomous agent…" + placeholder={defaultPrompt || "You are an autonomous agent…"} disabled={saving} aria-label="Heartbeat system prompt" ></textarea> + <p class="shrink-0 text-xs opacity-50"> + {#if inheriting} + Matches the workspace default — saving will inherit it (no override). + {:else if defaultPrompt !== ""} + Editing overrides the workspace default. + {:else} + Empty — no system prompt set. + {/if} + </p> </div> <div class="flex min-h-0 flex-1 flex-col gap-1"> |
