summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 21:15:08 +0900
committerAdam Malczewski <[email protected]>2026-06-26 21:15:08 +0900
commit1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9 (patch)
tree5856ba0553a3d625f246e930b7f8f23c88e589e3 /src/features
parenta8c9be77cf5dd6249d8ea531c4ce79d823a6f600 (diff)
downloaddispatch-web-1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9.tar.gz
dispatch-web-1f2b5764e4e558b7fc9ab2515a1b6e6dccf306a9.zip
feat(heartbeat): default system prompt to workspace default + reset button
Diffstat (limited to 'src/features')
-rw-r--r--src/features/heartbeat/index.ts3
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts45
-rw-r--r--src/features/heartbeat/logic/view-model.ts35
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte13
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte122
5 files changed, 200 insertions, 18 deletions
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">