From bbc85ff04b6009ff77a72b93c5853eecf9cb3e82 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:13:44 +0900 Subject: feat(header): remove copy + theme buttons; keep title, status, sidebar toggle These move to dedicated sidebar panels (Debug panel and Settings panel respectively) in follow-up commits. Header is now visibly cleaner: only the Dispatch title (left), connection status indicator, and the Sidebar toggle (right) remain. --- packages/frontend/src/lib/components/Header.svelte | 41 ---------------------- 1 file changed, 41 deletions(-) (limited to 'packages/frontend') diff --git a/packages/frontend/src/lib/components/Header.svelte b/packages/frontend/src/lib/components/Header.svelte index 713e916..3066e81 100644 --- a/packages/frontend/src/lib/components/Header.svelte +++ b/packages/frontend/src/lib/components/Header.svelte @@ -1,29 +1,8 @@ - -{#if showThemeSwitcher} - (showThemeSwitcher = false)} /> -{/if} -- cgit v1.2.3 From dd3c71e3d5c8c1b9b23bcf3fdbc34dc306a80570 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:14:30 +0900 Subject: feat(sidebar): add Debug panel with copy-conversation action New "Debug" panel option in the sidebar, grouping dev-facing actions. Currently exposes the Copy-conversation button (ported from the old header). Leaves room for additional debug actions without re-cluttering the header. The Copy action wraps `tabStore.copyConversation()` and shows a "Copied"/"Failed" affordance for 1.5s, matching the previous header behavior. --- .../frontend/src/lib/components/DebugPanel.svelte | 35 ++++++++++++++++++++++ .../src/lib/components/SidebarPanel.svelte | 4 +++ 2 files changed, 39 insertions(+) create mode 100644 packages/frontend/src/lib/components/DebugPanel.svelte (limited to 'packages/frontend') diff --git a/packages/frontend/src/lib/components/DebugPanel.svelte b/packages/frontend/src/lib/components/DebugPanel.svelte new file mode 100644 index 0000000..aea1ccb --- /dev/null +++ b/packages/frontend/src/lib/components/DebugPanel.svelte @@ -0,0 +1,35 @@ + + +
+
Debug
+ +
+

Conversation

+

+ Copy a structured plain-text dump of the active tab's conversation + (chunk shape included) for bug reports. +

+ +
+
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 206ed09..66fa6a4 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import type { CacheStats, KeyInfo, LogEntry, TaskItem } from "../types.js"; import CacheRatePanel from "./CacheRatePanel.svelte"; import ClaudeReset from "./ClaudeReset.svelte"; import ConfigPanel from "./ConfigPanel.svelte"; +import DebugPanel from "./DebugPanel.svelte"; import KeyUsage from "./KeyUsage.svelte"; import ModelSelector from "./ModelSelector.svelte"; import ModelStatus from "./ModelStatus.svelte"; @@ -95,6 +96,7 @@ const viewOptions = [ "Skills", "Tools", "Settings", + "Debug", ]; function addPanel() { @@ -181,6 +183,8 @@ function contentClass(_selected: string): string { {:else if panel.selected === "Settings"} + {:else if panel.selected === "Debug"} + {/if} -- cgit v1.2.3 From 751e411b3ab321129083f86f0be53687185abd87 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:15:43 +0900 Subject: feat(settings): inline theme picker into Settings panel The Theme button + ThemeSwitcher modal were a header-triggered modal. That doesn't belong in a sidebar-panel architecture, and theme picking is a UI preference that belongs alongside the other Settings entries. - Add a "Theme" section as the first block in SettingsPanel with the same theme list as ThemeSwitcher. - The localStorage key (`dispatch-theme`) and apply-on-change behavior are unchanged, so the boot-time theme apply in App.svelte's onMount keeps working without modification. - Delete the now-unused ThemeSwitcher.svelte component; no remaining importers. --- .../src/lib/components/SettingsPanel.svelte | 52 +++++++++++++++++++ .../src/lib/components/ThemeSwitcher.svelte | 58 ---------------------- 2 files changed, 52 insertions(+), 58 deletions(-) delete mode 100644 packages/frontend/src/lib/components/ThemeSwitcher.svelte (limited to 'packages/frontend') diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 392852a..efbaf5f 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -11,6 +11,42 @@ const { apiBase?: string; } = $props(); +// Theme picker — was a header-triggered modal (`ThemeSwitcher.svelte`); +// inlined here so theme picking lives in Settings alongside other UI +// preferences. The list and localStorage key must stay in sync with the +// boot-time theme apply in `App.svelte`'s `onMount`. +const THEMES = [ + "light", + "dark", + "dracula", + "night", + "nord", + "sunset", + "cyberpunk", + "forest", + "cmyk", + "coffee", + "caramellatte", + "garden", + "luxury", +] as const; + +const THEME_STORAGE_KEY = "dispatch-theme"; + +let currentTheme = $state( + (typeof localStorage !== "undefined" && localStorage.getItem(THEME_STORAGE_KEY)) || "dark", +); + +function selectTheme(theme: string): void { + currentTheme = theme; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem(THEME_STORAGE_KEY, theme); + } catch { + // Best-effort — private mode / quota. + } +} + let titleKeyId = $state(null); let titleModelId = $state(null); let availableModels = $state([]); @@ -136,6 +172,22 @@ $effect(() => {
Settings
+

Theme

+ + +
+

Title Generation Model

Used to generate short titles for new tabs after the first message.

diff --git a/packages/frontend/src/lib/components/ThemeSwitcher.svelte b/packages/frontend/src/lib/components/ThemeSwitcher.svelte deleted file mode 100644 index 418fcea..0000000 --- a/packages/frontend/src/lib/components/ThemeSwitcher.svelte +++ /dev/null @@ -1,58 +0,0 @@ - - - - - - -- cgit v1.2.3 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 From 6c377fba7d516ea89ef2d906a40785a997299b0c Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:45:36 +0900 Subject: fix(theme): consolidate boot apply and Settings picker into shared module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gemini review surfaced that App.svelte (onMount theme apply) and SettingsPanel.svelte (theme