summaryrefslogtreecommitdiffhomepage
path: root/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-26 19:52:47 +0900
committerAdam Malczewski <[email protected]>2026-06-26 19:52:47 +0900
commit51da5df8fc70efc2febda238faeaeacb21713699 (patch)
tree15727c2a4e38dccb4f44123c387f43abe117dbad /src
parentf582642aeed9c79247e805545d434c4a261be781 (diff)
downloaddispatch-web-51da5df8fc70efc2febda238faeaeacb21713699.tar.gz
dispatch-web-51da5df8fc70efc2febda238faeaeacb21713699.zip
feat(heartbeat): prompt editor modal + hours/minutes timer + backend handoff
Diffstat (limited to 'src')
-rw-r--r--src/app/App.svelte1
-rw-r--r--src/features/heartbeat/index.ts3
-rw-r--r--src/features/heartbeat/logic/view-model.test.ts63
-rw-r--r--src/features/heartbeat/logic/view-model.ts41
-rw-r--r--src/features/heartbeat/ui/HeartbeatView.svelte103
-rw-r--r--src/features/heartbeat/ui/PromptEditor.svelte307
-rw-r--r--src/features/system-prompt/index.ts2
7 files changed, 470 insertions, 50 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte
index f19df62..f9fecc7 100644
--- a/src/app/App.svelte
+++ b/src/app/App.svelte
@@ -680,6 +680,7 @@
saveConfig={saveHeartbeatConfig}
loadRuns={loadHeartbeatRuns}
stopRun={stopHeartbeatRun}
+ loadVariables={loadSystemPromptVariablesPrompt}
onOpenRun={(run) => (heartbeatRun = run)}
/>
{/if}
diff --git a/src/features/heartbeat/index.ts b/src/features/heartbeat/index.ts
index 00c3eeb..a54a448 100644
--- a/src/features/heartbeat/index.ts
+++ b/src/features/heartbeat/index.ts
@@ -20,16 +20,19 @@ export {
formatRunTime,
formDiffers,
formFromConfig,
+ joinInterval,
normalizeHeartbeatConfig,
normalizeHeartbeatRuns,
normalizeInterval,
patchFromForm,
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";
export { default as RunModal } from "./ui/RunModal.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/heartbeat/logic/view-model.test.ts b/src/features/heartbeat/logic/view-model.test.ts
index fc43112..fe97496 100644
--- a/src/features/heartbeat/logic/view-model.test.ts
+++ b/src/features/heartbeat/logic/view-model.test.ts
@@ -9,11 +9,13 @@ import {
formatRunTime,
formDiffers,
formFromConfig,
+ joinInterval,
normalizeHeartbeatConfig,
normalizeHeartbeatRuns,
normalizeInterval,
patchFromForm,
relativeLabel,
+ splitInterval,
statusLabelFor,
viewRun,
viewRuns,
@@ -120,10 +122,12 @@ describe("viewRun / viewRuns", () => {
});
describe("config form", () => {
- it("emptyForm has defaults (disabled, default interval, default effort)", () => {
+ it("emptyForm has defaults (disabled, default interval split, default effort)", () => {
const f = emptyForm();
expect(f.enabled).toBe(false);
- expect(f.intervalMinutes).toBe(DEFAULT_INTERVAL_MINUTES);
+ // 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("");
@@ -139,6 +143,25 @@ describe("config form", () => {
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({
@@ -149,7 +172,9 @@ describe("config form", () => {
}),
);
expect(f.enabled).toBe(false); // non-true → false
- expect(f.intervalMinutes).toBe(1); // clamped
+ // -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 → ""
});
@@ -164,8 +189,24 @@ describe("config form", () => {
expect(normalizeInterval(undefined)).toBe(DEFAULT_INTERVAL_MINUTES);
});
- it("patchFromForm clamps interval + carries every field", () => {
+ 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);
@@ -175,6 +216,13 @@ describe("config form", () => {
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);
@@ -188,6 +236,13 @@ describe("config form", () => {
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);
diff --git a/src/features/heartbeat/logic/view-model.ts b/src/features/heartbeat/logic/view-model.ts
index f5b9f96..b79fff6 100644
--- a/src/features/heartbeat/logic/view-model.ts
+++ b/src/features/heartbeat/logic/view-model.ts
@@ -159,11 +159,16 @@ function dateLabel(epochMs: number): string {
* `HeartbeatConfig` that the inputs bind to. `reasoningEffort` is resolved to
* an effective level for the `<select>` (null ⇒ default `high`), exactly like
* the per-conversation selector.
+ *
+ * The interval is split into `intervalHours` + `intervalMinutes` (0–59) for the
+ * UI (two inputs), and recombined to a total-minutes value at the patch seam
+ * (`patchFromForm`); the backend stores a single `intervalMinutes`.
*/
export interface HeartbeatFormState {
enabled: boolean;
systemPrompt: string;
taskPrompt: string;
+ intervalHours: number;
intervalMinutes: number;
model: string;
reasoningEffort: ReasoningEffort;
@@ -172,16 +177,33 @@ export interface HeartbeatFormState {
/** The default interval (minutes) shown for an empty/unset config. */
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 };
+}
+
+/** 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);
+}
+
/**
* Seed the editable form state from a loaded config, applying safe defaults for
* 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 ?? "",
- intervalMinutes: normalizeInterval(config.intervalMinutes),
+ intervalHours: hours,
+ intervalMinutes: minutes,
model: typeof config.model === "string" ? config.model : "",
reasoningEffort: effectiveEffort(config.reasoningEffort ?? null),
};
@@ -189,11 +211,13 @@ export function formFromConfig(config: HeartbeatConfig): HeartbeatFormState {
/** An empty form (before the config loads). */
export function emptyForm(): HeartbeatFormState {
+ const { hours, minutes } = splitInterval(DEFAULT_INTERVAL_MINUTES);
return {
enabled: false,
systemPrompt: "",
taskPrompt: "",
- intervalMinutes: DEFAULT_INTERVAL_MINUTES,
+ intervalHours: hours,
+ intervalMinutes: minutes,
model: "",
reasoningEffort: DEFAULT_REASONING_EFFORT,
};
@@ -209,16 +233,17 @@ export function normalizeInterval(value: unknown): number {
}
/**
- * The patch to PUT when persisting the form. Only `intervalMinutes` is clamped;
- * text fields are sent verbatim. `reasoningEffort` is always present (a resolved
- * level) since the heartbeat has no per-run override — it persists the level.
+ * The patch to PUT when persisting the form. The split hours+minutes are
+ * recombined into a single `intervalMinutes` (clamped); text fields are sent
+ * verbatim. `reasoningEffort` is always present (a resolved level) since the
+ * 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: normalizeInterval(form.intervalMinutes),
+ intervalMinutes: joinInterval(form.intervalHours, form.intervalMinutes),
model: form.model,
reasoningEffort: form.reasoningEffort,
};
@@ -226,11 +251,13 @@ export function patchFromForm(form: HeartbeatFormState): HeartbeatConfigPatch {
/** 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.intervalMinutes !== normalizeInterval(config.intervalMinutes) ||
+ form.intervalHours !== hours ||
+ form.intervalMinutes !== minutes ||
form.model !== (typeof config.model === "string" ? config.model : "") ||
form.reasoningEffort !== effectiveEffort(config.reasoningEffort ?? null)
);
diff --git a/src/features/heartbeat/ui/HeartbeatView.svelte b/src/features/heartbeat/ui/HeartbeatView.svelte
index 7e0e7b7..ba61759 100644
--- a/src/features/heartbeat/ui/HeartbeatView.svelte
+++ b/src/features/heartbeat/ui/HeartbeatView.svelte
@@ -5,12 +5,10 @@
import {
badgeForStatus,
type Badge,
- DEFAULT_INTERVAL_MINUTES,
emptyForm,
effortOptions,
formDiffers,
formFromConfig,
- normalizeInterval,
patchFromForm,
viewRuns,
type HeartbeatFormState,
@@ -22,6 +20,8 @@
SaveHeartbeatConfig,
StopHeartbeatRun,
} from "../logic/types";
+ import type { LoadSystemPromptVariables } from "../../system-prompt";
+ import PromptEditor from "./PromptEditor.svelte";
let {
models,
@@ -29,6 +29,7 @@
saveConfig,
loadRuns,
stopRun,
+ loadVariables,
onOpenRun,
}: {
/** The available model names (for the config's model dropdown). */
@@ -37,6 +38,8 @@
saveConfig: SaveHeartbeatConfig;
loadRuns: LoadHeartbeatRuns;
stopRun: StopHeartbeatRun;
+ /** Load the available system-prompt variables (palette in the prompt editor). */
+ loadVariables: LoadSystemPromptVariables;
/** Open a run's chat in the fullscreen modal (composition-root wires the live watch). */
onOpenRun: (run: HeartbeatRunView) => void;
} = $props();
@@ -60,6 +63,7 @@
let saveError = $state<string | null>(null);
let justSaved = $state(false);
let hasConfig = $state(false);
+ let promptEditorOpen = $state(false);
const hasChanges = $derived(formDiffers(form, loadedConfig) && hasConfig);
@@ -230,30 +234,20 @@
{#if configError}
<p class="text-xs text-error">{configError}</p>
{:else}
- <!-- System prompt -->
+ <!-- Prompts (open the full-page editor) -->
<section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">System prompt</span>
- <textarea
- class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs"
- placeholder="You are an autonomous agent…"
- value={form.systemPrompt}
- disabled={saving || configLoading}
- oninput={(e) => (form = { ...form, systemPrompt: e.currentTarget.value })}
- aria-label="Heartbeat system prompt"
- ></textarea>
- </section>
-
- <!-- Task prompt -->
- <section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Task prompt</span>
- <textarea
- class="textarea textarea-bordered textarea-sm h-20 w-full font-mono text-xs"
- placeholder="Check the system status and report…"
- value={form.taskPrompt}
+ <span class="text-xs font-semibold uppercase opacity-60">Prompts</span>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
disabled={saving || configLoading}
- oninput={(e) => (form = { ...form, taskPrompt: e.currentTarget.value })}
- aria-label="Heartbeat task prompt"
- ></textarea>
+ onclick={() => (promptEditorOpen = true)}
+ >
+ Edit prompts
+ </button>
+ <p class="text-xs opacity-50">
+ Open the editor for the system + task prompts (with a variable palette).
+ </p>
</section>
<!-- Model + reasoning effort -->
@@ -297,32 +291,48 @@
</div>
</section>
- <!-- Interval -->
+ <!-- Interval (hours + minutes) -->
<section class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Interval (minutes)</span>
+ <span class="text-xs font-semibold uppercase opacity-60">Interval</span>
<div class="flex items-center gap-2">
<input
type="number"
- class="input input-bordered input-sm w-24"
- min="1"
- max="1440"
- placeholder={String(DEFAULT_INTERVAL_MINUTES)}
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="24"
+ value={form.intervalHours}
+ disabled={saving || configLoading}
+ oninput={(e) => {
+ const n = Number.parseInt(e.currentTarget.value, 10);
+ form = { ...form, intervalHours: Number.isNaN(n) ? 0 : n };
+ }}
+ onchange={(e) => {
+ const clamped = Math.max(0, Math.min(24, form.intervalHours));
+ form = { ...form, intervalHours: clamped };
+ e.currentTarget.value = String(clamped);
+ }}
+ aria-label="Heartbeat interval hours"
+ />
+ <span class="text-xs opacity-60">h</span>
+ <input
+ type="number"
+ class="input input-bordered input-sm w-20"
+ min="0"
+ max="59"
value={form.intervalMinutes}
disabled={saving || configLoading}
oninput={(e) => {
const n = Number.parseInt(e.currentTarget.value, 10);
- form = {
- ...form,
- intervalMinutes: Number.isNaN(n) ? DEFAULT_INTERVAL_MINUTES : n,
- };
+ form = { ...form, intervalMinutes: Number.isNaN(n) ? 0 : n };
}}
onchange={(e) => {
- form = { ...form, intervalMinutes: normalizeInterval(form.intervalMinutes) };
- e.currentTarget.value = String(form.intervalMinutes);
+ const clamped = Math.max(0, Math.min(59, form.intervalMinutes));
+ form = { ...form, intervalMinutes: clamped };
+ e.currentTarget.value = String(clamped);
}}
- aria-label="Heartbeat interval in minutes"
+ aria-label="Heartbeat interval minutes"
/>
- <span class="text-xs opacity-60">min between runs</span>
+ <span class="text-xs opacity-60">m between runs</span>
</div>
</section>
@@ -419,3 +429,20 @@
{/if}
</section>
</div>
+
+{#if promptEditorOpen}
+ <PromptEditor
+ systemPrompt={form.systemPrompt}
+ taskPrompt={form.taskPrompt}
+ {loadVariables}
+ {saveConfig}
+ onSaved={(systemPrompt, taskPrompt) => {
+ // Sync the form + the diff baseline so the main Save button + formDiffers
+ // stay accurate (the editor persisted the prompts already).
+ form = { ...form, systemPrompt, taskPrompt };
+ loadedConfig = { ...loadedConfig, systemPrompt, taskPrompt };
+ justSaved = true;
+ }}
+ onClose={() => (promptEditorOpen = false)}
+ />
+{/if}
diff --git a/src/features/heartbeat/ui/PromptEditor.svelte b/src/features/heartbeat/ui/PromptEditor.svelte
new file mode 100644
index 0000000..2377428
--- /dev/null
+++ b/src/features/heartbeat/ui/PromptEditor.svelte
@@ -0,0 +1,307 @@
+<script lang="ts">
+ import type { SystemPromptVariable } from "@dispatch/transport-contract";
+ import { tick, untrack } from "svelte";
+ import {
+ buildTag,
+ groupVariables,
+ insertTag,
+ isDynamicVariable,
+ type LoadSystemPromptVariables,
+ } from "../../system-prompt";
+ import type { SaveHeartbeatConfig } from "../logic/types";
+
+ let {
+ systemPrompt,
+ taskPrompt,
+ loadVariables,
+ saveConfig,
+ onSaved,
+ onClose,
+ }: {
+ /** The current system prompt (seeded from the loaded config). */
+ systemPrompt: string;
+ /** The current task prompt (seeded from the loaded config). */
+ taskPrompt: string;
+ /** Load the available variables (`GET /system-prompt/variables`). */
+ loadVariables: LoadSystemPromptVariables;
+ /** 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). */
+ 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.
+ let system = $state(untrack(() => systemPrompt));
+ let task = $state(untrack(() => taskPrompt));
+ /** Snapshots at open, to diff against (drives Save + Reset). */
+ let loadedSystem = $state(untrack(() => systemPrompt));
+ let loadedTask = $state(untrack(() => taskPrompt));
+
+ let variables = $state<readonly SystemPromptVariable[]>([]);
+ let varsLoading = $state(false);
+ let varsError = $state<string | null>(null);
+
+ let saving = $state(false);
+ let saveError = $state<string | null>(null);
+ let justSaved = $state(false);
+
+ // The textarea currently focused — variable insertion targets THIS one.
+ type Field = "system" | "task";
+ let activeField = $state<Field>("system");
+ let systemEl = $state<HTMLTextAreaElement | null>(null);
+ let taskEl = $state<HTMLTextAreaElement | null>(null);
+
+ const groups = $derived(groupVariables(variables));
+ const hasChanges = $derived(system !== loadedSystem || task !== loadedTask);
+
+ async function loadVars(): Promise<void> {
+ untrack(() => {
+ varsLoading = true;
+ varsError = null;
+ });
+ const result = await loadVariables();
+ varsLoading = false;
+ if (result.ok) {
+ variables = result.variables;
+ } else {
+ varsError = result.error;
+ }
+ }
+
+ async function save(): Promise<void> {
+ if (saving || !hasChanges) return;
+ saving = true;
+ saveError = null;
+ justSaved = false;
+ const result = await saveConfig({ systemPrompt: system, taskPrompt: task });
+ saving = false;
+ if (result === null) return;
+ if (result.ok) {
+ loadedSystem = system;
+ loadedTask = task;
+ justSaved = true;
+ onSaved(system, task);
+ } else {
+ saveError = result.error;
+ }
+ }
+
+ function reset(): void {
+ system = loadedSystem;
+ task = loadedTask;
+ 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 +
+ * its own text (so a tag never lands in the wrong box).
+ */
+ async function insertAtActive(tag: string): Promise<void> {
+ const el = activeField === "system" ? systemEl : taskEl;
+ if (el === null) return;
+ const start = el.selectionStart;
+ const end = el.selectionEnd;
+ if (activeField === "system") {
+ const ins = insertTag(system, tag, start, end);
+ system = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ } else {
+ const ins = insertTag(task, tag, start, end);
+ task = ins.template;
+ await tick();
+ el.focus();
+ el.setSelectionRange(ins.cursor, ins.cursor);
+ }
+ }
+
+ /** Dynamic (file:<path>) variable: build the tag from the input + insert. */
+ async function insertDynamic(type: string, path: string): Promise<void> {
+ const trimmed = path.trim();
+ if (trimmed.length === 0) return;
+ await insertAtActive(buildTag(type, trimmed));
+ }
+
+ function onKeydown(e: KeyboardEvent): void {
+ if (e.key === "Escape") onClose();
+ }
+
+ // Load the variable palette once on open.
+ $effect(() => {
+ void loadVars();
+ });
+</script>
+
+<svelte:window onkeydown={onKeydown} />
+
+<!-- svelte-ignore a11y_no_static_element_interactions -->
+<div
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
+ role="dialog"
+ aria-modal="true"
+ aria-label="Heartbeat prompt editor"
+ tabindex="-1"
+ onclick={onClose}
+ onkeydown={onKeydown}
+>
+ <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
+ <div
+ class="flex h-[85vh] w-full max-w-6xl flex-col overflow-hidden rounded-box bg-base-100 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <!-- Header -->
+ <div class="flex shrink-0 items-center justify-between border-b border-base-300 px-4 py-3">
+ <div class="flex items-center gap-2">
+ <h2 class="text-sm font-semibold">Heartbeat Prompts</h2>
+ {#if varsLoading}
+ <span class="loading loading-spinner loading-xs"></span>
+ {/if}
+ </div>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm btn-square"
+ onclick={onClose}
+ aria-label="Close prompt editor"
+ >
+ ✕
+ </button>
+ </div>
+
+ <!-- Body: half editor (two boxes) / half variables -->
+ <div class="flex min-h-0 flex-1">
+ <!-- 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>
+ <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…"
+ disabled={saving}
+ aria-label="Heartbeat system prompt"
+ ></textarea>
+ </div>
+
+ <div class="flex min-h-0 flex-1 flex-col gap-1">
+ <span class="shrink-0 text-xs font-semibold uppercase opacity-60">Task prompt</span>
+ <textarea
+ bind:this={taskEl}
+ bind:value={task}
+ onfocus={() => (activeField = "task")}
+ class="textarea textarea-bordered min-h-0 w-full flex-1 resize-none font-mono text-xs"
+ placeholder="Check the system status and report…"
+ disabled={saving}
+ aria-label="Heartbeat task prompt"
+ ></textarea>
+ </div>
+
+ <div class="flex shrink-0 flex-wrap items-center gap-2">
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !hasChanges}
+ onclick={save}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-ghost btn-sm"
+ disabled={!hasChanges}
+ onclick={reset}
+ >
+ Reset
+ </button>
+ {#if justSaved && !hasChanges}
+ <span class="text-xs text-success">Saved.</span>
+ {:else if hasChanges}
+ <span class="text-xs opacity-60">Unsaved changes</span>
+ {/if}
+ </div>
+
+ {#if saveError}
+ <p class="shrink-0 text-xs text-error">{saveError}</p>
+ {/if}
+ </div>
+
+ <!-- Right: variable palette -->
+ <div class="flex w-1/2 min-w-0 flex-col overflow-y-auto p-4">
+ <h3 class="mb-2 shrink-0 text-xs font-semibold uppercase opacity-60">Variables</h3>
+ <p class="mb-3 shrink-0 text-xs opacity-50">
+ Click a variable to insert it into the focused prompt box.
+ </p>
+ {#if varsError}
+ <p class="text-xs text-error">{varsError}</p>
+ {:else if groups.length === 0 && !varsLoading}
+ <p class="text-xs opacity-60">No variables available.</p>
+ {:else}
+ <div class="flex flex-col gap-3">
+ {#each groups as group (group.type)}
+ <div class="rounded-box bg-base-200 p-3">
+ <span class="text-xs font-semibold uppercase opacity-70">{group.type}</span>
+ <div class="mt-2 flex flex-wrap gap-1">
+ {#each group.variables as variable (variable.type + variable.name)}
+ {#if isDynamicVariable(variable)}
+ <!-- Dynamic (file:<path>) variable: a path input + Insert button. -->
+ <div class="flex items-center gap-1">
+ <input
+ type="text"
+ class="input input-bordered input-xs w-32 font-mono"
+ placeholder={variable.name}
+ onkeydown={(e) => {
+ if (e.key === "Enter") {
+ const v = e.currentTarget.value;
+ void insertDynamic(variable.type, v);
+ e.currentTarget.value = "";
+ }
+ }}
+ />
+ <button
+ type="button"
+ class="btn btn-xs"
+ onclick={(e) => {
+ const input = (e.currentTarget as HTMLButtonElement)
+ .previousElementSibling as HTMLInputElement | null;
+ if (input !== null) {
+ void insertDynamic(variable.type, input.value);
+ input.value = "";
+ }
+ }}
+ >
+ Insert
+ </button>
+ </div>
+ {:else}
+ <button
+ type="button"
+ class="btn btn-xs"
+ title={variable.description}
+ onclick={() =>
+ void insertAtActive(buildTag(variable.type, variable.name))}
+ >
+ {variable.name}
+ </button>
+ {/if}
+ {/each}
+ </div>
+ </div>
+ {/each}
+ </div>
+ {/if}
+ </div>
+ </div>
+ </div>
+</div>
diff --git a/src/features/system-prompt/index.ts b/src/features/system-prompt/index.ts
index 7c77675..142d4b1 100644
--- a/src/features/system-prompt/index.ts
+++ b/src/features/system-prompt/index.ts
@@ -7,7 +7,7 @@ export type {
SystemPromptVariablesResult,
VariableGroup,
} from "./logic/view-model";
-export { buildTag, groupVariables, insertTag } from "./logic/view-model";
+export { buildTag, groupVariables, insertTag, isDynamicVariable } from "./logic/view-model";
export { default as SystemPromptBuilder } from "./ui/SystemPromptBuilder.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */