From 786bc4336c9e4619385e9bce105f95727bcbb6ca Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:28:37 +0900 Subject: feat(frontend): ntfy.sh settings block in SettingsPanel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'Notifications (ntfy.sh)' section below 'Backend URL' with: - Enable toggle (master switch) - Topic URL field (with security hint: anyone with the URL can read) - Optional auth token (password input; placeholder reflects whether one is already stored, and a 'Clear stored token' button surfaces only when hasAuthToken=true) - Per-event-type checkboxes driven by the eventTypes catalog returned from GET /notifications (so adding a new event type in core doesn't require a frontend change) - Save + Send test buttons, with inline success/error feedback The component hand-mirrors the NtfyConfig shape rather than importing it from @dispatch/core — matching the existing pattern (lib/types.ts mirrors a few core types) to keep node-only barrels out of the browser bundle. --- .../src/lib/components/SettingsPanel.svelte | 256 +++++++++++++++++++++ 1 file changed, 256 insertions(+) (limited to 'packages/frontend') diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 392852a..1d9ebf7 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -20,6 +20,59 @@ let localChunkLimit = $state(appSettings.chunkLimit); let backendUrl = $state(config.apiBase); let backendUrlSaved = $state(false); +// ─── ntfy.sh push notifications ────────────────────────────────── +// Server-side schema mirror — kept inline rather than imported to avoid +// pulling a node-only barrel into the browser bundle (frontend already +// hand-mirrors a few core types in lib/types.ts for the same reason). +type NotificationEventType = + | "turn-completed" + | "turn-error" + | "permission-required" + | "agent-spawned"; + +interface NtfyConfigView { + enabled: boolean; + topicUrl: string; + authToken: string; + hasAuthToken?: boolean; + events: Record; +} + +const NTFY_EVENT_LABELS: Record = { + "turn-completed": "Turn completed", + "turn-error": "Turn error", + "permission-required": "Permission requested", + "agent-spawned": "User agent spawned", +}; + +const DEFAULT_NTFY: NtfyConfigView = { + enabled: false, + topicUrl: "", + authToken: "", + hasAuthToken: false, + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, +}; + +let ntfy = $state({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } }); +let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save +let ntfyEventOrder = $state([ + "turn-completed", + "turn-error", + "permission-required", + "agent-spawned", +]); +let ntfySaving = $state(false); +let ntfySaveError = $state(null); +let ntfySaveOk = $state(false); +let ntfyTesting = $state(false); +let ntfyTestResult = $state(null); +let ntfyTestOk = $state(false); + function onChunkLimitChange(e: Event): void { const input = e.target as HTMLInputElement; const val = parseInt(input.value, 10); @@ -73,6 +126,108 @@ async function loadSettings(): Promise { } catch { // ignore } + await loadNtfy(); +} + +async function loadNtfy(): Promise { + try { + const res = await fetch(`${apiBase}/notifications`); + if (!res.ok) return; + const data = (await res.json()) as { + config: NtfyConfigView; + eventTypes?: NotificationEventType[]; + }; + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) { + ntfyEventOrder = data.eventTypes; + } + } catch { + // ignore + } +} + +async function saveNtfy(): Promise { + ntfySaving = true; + ntfySaveError = null; + ntfySaveOk = false; + try { + // `authToken: undefined` ⇒ server keeps the existing token. + // `authToken: ""` ⇒ explicit clear (the user typed and cleared). + const payload: Partial & { authToken?: string } = { + enabled: ntfy.enabled, + topicUrl: ntfy.topicUrl, + events: ntfy.events, + }; + if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput; + const res = await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await res.json()) as { config?: NtfyConfigView; error?: string }; + if (!res.ok) { + ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`; + return; + } + if (data.config) { + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + } + ntfyAuthTokenInput = ""; + ntfySaveOk = true; + setTimeout(() => { + ntfySaveOk = false; + }, 2000); + } catch (e) { + ntfySaveError = e instanceof Error ? e.message : "Network error"; + } finally { + ntfySaving = false; + } +} + +async function sendNtfyTest(): Promise { + ntfyTesting = true; + ntfyTestResult = null; + ntfyTestOk = false; + try { + const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" }); + const data = (await res.json()) as { ok?: boolean; error?: string; status?: number }; + if (!res.ok || !data.ok) { + ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`; + return; + } + ntfyTestOk = true; + ntfyTestResult = "Sent — check your ntfy client."; + } catch (e) { + ntfyTestResult = e instanceof Error ? e.message : "Network error"; + } finally { + ntfyTesting = false; + } +} + +function clearNtfyAuthToken(): void { + // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing). + ntfyAuthTokenInput = ""; + ntfy = { ...ntfy, hasAuthToken: false }; + // Send a save with explicit empty string to clear server-side. + void (async () => { + try { + await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ authToken: "" }), + }); + } catch { + // ignore + } + })(); } async function toggleAutoExpand(): Promise { @@ -222,5 +377,106 @@ $effect(() => { {#if backendUrlSaved}

Saved. Reload the page to apply.

{/if} + +
+ +

Notifications (ntfy.sh)

+

+ Push notifications to your phone when things happen here. Subscribe to your topic in the + ntfy.sh app to receive them. +

+ + + + + + + +
+ Notify me on: + {#each ntfyEventOrder as evType (evType)} + + {/each} +
+ +
+ + +
+ {#if ntfySaveOk} +

Saved.

+ {/if} + {#if ntfySaveError} +

{ntfySaveError}

+ {/if} + {#if ntfyTestResult} +

{ntfyTestResult}

+ {/if} -- cgit v1.2.3