diff options
| -rw-r--r-- | backend-handoff.md | 91 | ||||
| -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 | 63 | ||||
| -rw-r--r-- | src/features/heartbeat/logic/view-model.ts | 41 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/HeartbeatView.svelte | 103 | ||||
| -rw-r--r-- | src/features/heartbeat/ui/PromptEditor.svelte | 307 | ||||
| -rw-r--r-- | src/features/system-prompt/index.ts | 2 |
8 files changed, 557 insertions, 54 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index 6d14ad4..7f4ddca 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,10 +5,10 @@ > **From:** dispatch-web orchestrator · **To:** `../dispatch-backend` orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (AGENTS.md § Backend seam) — every cross-repo ask flows through here. -_Last updated: 2026-06-25 (§2f ADDED — Heartbeat feature shipped: workspace autonomous-agent config + run -history + live run-chat modal; new `src/features/heartbeat/` feature library + `watchConversation`/`unwatchConversation` -on the store; 837/837 tests green, typecheck 0/0, biome clean, build OK. The heartbeat API is a plain REST surface — -NOT a transport-contract type — so the FE owns the types locally; see §2f for the contract-swap note)._ +_Last updated: 2026-06-26 (§2g ADDED — Heartbeat follow-up UI: prompt editor modal (reuses `GET /system-prompt/variables`; +opens 1 backend ask CR-HB-1: confirm/implement `[type:name]` variable resolution in the heartbeat system/task prompts) ++ hours/minutes interval timer (FE-only conversion, no backend change). typecheck 0/0, tests green, biome clean, build OK). +§2f (the initial heartbeat slice) is unchanged._ **FE is current on `[email protected]` / `[email protected]` / `[email protected]`.** Open asks: **CR-9** (`system:os` should detect WSL + include Linux distro — backend behavior change, no contract bump). The SSH-divergence (§2d) is RESOLVED. @@ -504,6 +504,89 @@ later, the heartbeat view kind renames in lockstep with the rest. --- +## 2g. Heartbeat follow-up UI — prompt editor modal + hours/minutes timer → **FE BUILT; 1 BACKEND ASK** + +A follow-up to §2f (branch `feature/heartbeat`, uncommitted-to-this-handoff commit). Two UI changes on the heartbeat +config panel, each analyzed below for backend impact. **One needs a backend change (variable resolution in the +heartbeat prompts); the other needs none.** + +### Change A — Prompt editor modal (replaces the two textareas) → **1 BACKEND ASK (CR-HB-1)** + +The config panel's two inline textareas (system prompt + task prompt) are replaced by a single "Edit prompts" button +that opens a full-width modal (`src/features/heartbeat/ui/PromptEditor.svelte`): +- LEFT side: two stacked text editors (top = system prompt, bottom = task prompt). +- RIGHT side: the variable palette (grouped by type, same `[type:name]` tag insertion as the global system-prompt + builder). Clicking a variable inserts `[type:name]` at the cursor of whichever textarea is focused. +- Save persists both prompts via the existing `PUT /workspaces/:id/heartbeat` with `{ systemPrompt, taskPrompt }` + (a partial patch — no new endpoint, no data-shape change). + +**What the FE reuses (NO new endpoint needed):** +- The variable palette is sourced from the EXISTING global `GET /system-prompt/variables` endpoint — the SAME one the + global System Prompt builder uses. The heartbeat feature imports the pure helpers (`buildTag`, `groupVariables`, + `insertTag`, `isDynamicVariable`) from `features/system-prompt` (its public `index.ts` — `isDynamicVariable` was added + to that export in this slice, an additive cross-unit seam change). So there is **no new variable source or endpoint**; + the heartbeat prompt editor offers the exact same variable tags the global system prompt does. +- The persisted strings carry literal `[type:name]` placeholders (plain text), exactly like the global template. + +**CR-HB-1 — Resolve `[type:name]` variables in the heartbeat system/task prompts → ASK (needs backend confirmation + likely implementation):** + +The global system-prompt template is resolved by the backend: `[type:name]` placeholders (e.g. `[system:os]`, +`[system:date]`, `[file:path]`, `[if system:wsl]…`) are substituted with their resolved values at construction time +(once per conversation at first turn, then persisted for prompt-cache safety). **The critical question: does the +backend apply this SAME variable resolution to the heartbeat's `systemPrompt` and `taskPrompt` fields?** + +- If YES (the heartbeat prompts already flow through the same resolver as the global template): **no backend change + needed** — the FE just inserts the same `[type:name]` tags and the backend resolves them. Confirm + close. +- If NO (the heartbeat prompts are inserted into the model turn as raw text, so `[system:os]` would reach the model + literally as the string `[system:os]` rather than the resolved OS string): **the backend should resolve `[type:name]` + placeholders in the heartbeat `systemPrompt` + `taskPrompt` using the SAME resolver/variable set as the global + system prompt** (so a variable inserted in either place resolves identically). This is the expected gap — the heartbeat + prompts are a NEW surface that predates the variable system, so they likely bypass the resolver. + + - Resolution timing: mirror the global template's behavior — resolve once when a heartbeat run's turn is constructed + (NOT on every interval tick, to stay prompt-cache-safe; a stable resolved prompt keeps the cache warm across runs). + If the heartbeat re-resolves per-run anyway (intervals may want fresh `[system:time]`), that's a backend product + decision — the FE doesn't care WHEN it resolves, only THAT `[type:name]` becomes its value. + - Variable set: the SAME catalog `GET /system-prompt/variables` returns (system/file/prompt/git groups). No + heartbeat-specific variables are required for this slice. + - No wire/transport-contract/ui-contract change needed — this is a backend behavior change (the prompt strings are + still `string`; the FE is unaffected once the backend resolves them). + +**Optional future enhancement (NOT required now):** heartbeat-specific variables (e.g. `[heartbeat:runCount]`, +`[heartbeat:lastResult]`, `[heartbeat:elapsed]`). The FE's prompt editor would offer these if `GET /system-prompt/variables` +(or a new `GET /workspaces/:id/heartbeat/variables`) returned them. Defer until there's a product need. + +### Change B — Hours + minutes interval timer → **NO backend change needed** + +The interval input is split into two fields: an HOURS input (left) + a MINUTES input (right, 0–59). The conversion is +entirely FE-side: +- On load: the backend's single `intervalMinutes` is split into `{ hours, minutes }` via the pure `splitInterval` + helper (`Math.floor(total/60)` hours, remainder minutes). +- On save: the FE recombines via `joinInterval(hours, minutes)` → `hours*60 + minutes` (clamped to the 1–1440 range + = 1 min–24 h) and sends a SINGLE `intervalMinutes` integer to `PUT /workspaces/:id/heartbeat`. + +So **the backend's `intervalMinutes` field is UNCHANGED** — still one integer of total minutes. The hours/minutes split +is pure FE presentation. No endpoint, data-shape, or behavior change. (The existing clamp to 1–1440 also stands; a +0h0m entry clamps to 1 minute — the FE guards this, and the backend's own validation should too, but that's pre-existing.) + +### FE summary (this slice) +- `src/features/heartbeat/logic/view-model.ts`: `HeartbeatFormState` now carries `intervalHours` + `intervalMinutes` + (0–59); new pure `splitInterval`/`joinInterval` round-trip helpers; `formFromConfig`/`patchFromForm`/`formDiffers` + updated to split/recombine. +4 tests (split/join round-trip, form split, differs-after-edit). +- `src/features/heartbeat/ui/PromptEditor.svelte` (new): two-pane modal (system+task editors left, variable palette + right), focus-aware variable insertion, Save/Reset. Reuses `features/system-prompt`'s pure helpers. +- `src/features/heartbeat/ui/HeartbeatView.svelte`: two textareas → "Edit prompts" button + modal; interval → hours+minutes inputs. +- `src/features/system-prompt/index.ts`: added `isDynamicVariable` to the public exports (additive — needed by the + heartbeat editor's dynamic `file:<path>` row). +- `src/app/App.svelte`: passes `loadVariables={loadSystemPromptVariablesPrompt}` to `HeartbeatView`. + +**Verification:** typecheck 0/0, tests green, biome clean, build OK (see the commit). The variable-resolution behavior +(CR-HB-1) can only be confirmed end-to-end against a running backend with variable-laden heartbeat prompts — a human +should set `[system:os]` in a heartbeat prompt via the new editor, trigger a run, and confirm the resolved value (not the +literal `[system:os]`) appears in the model's context / run transcript. + +--- + ## 3. Likely NEXT backend asks (heads-up, not yet requested) - **Model max context-window LIMIT** → **CONSUMED ✅** — `GET /models` now returns 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. */ |
