From 51da5df8fc70efc2febda238faeaeacb21713699 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Fri, 26 Jun 2026 19:52:47 +0900 Subject: feat(heartbeat): prompt editor modal + hours/minutes timer + backend handoff --- backend-handoff.md | 91 ++++++- src/app/App.svelte | 1 + src/features/heartbeat/index.ts | 3 + src/features/heartbeat/logic/view-model.test.ts | 63 ++++- src/features/heartbeat/logic/view-model.ts | 41 +++- src/features/heartbeat/ui/HeartbeatView.svelte | 103 +++++--- src/features/heartbeat/ui/PromptEditor.svelte | 307 ++++++++++++++++++++++++ src/features/system-prompt/index.ts | 2 +- 8 files changed, 557 insertions(+), 54 deletions(-) create mode 100644 src/features/heartbeat/ui/PromptEditor.svelte 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 `ui-contract@0.2.0` / `transport-contract@0.22.0` / `wire@0.12.0`.** 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:` 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 ` - - - -
- Task prompt - + onclick={() => (promptEditorOpen = true)} + > + Edit prompts + +

+ Open the editor for the system + task prompts (with a variable palette). +

@@ -297,32 +291,48 @@ - +
- Interval (minutes) + Interval
{ + 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" + /> + h + { 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" /> - min between runs + m between runs
@@ -419,3 +429,20 @@ {/if} + +{#if promptEditorOpen} + { + // 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 @@ + + + + + + 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. */ -- cgit v1.2.3