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(-) 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 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 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 60999dc48d8c06a10ff8f5b3f6edb1d220fd85ca Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:17:11 +0900 Subject: docs: add HANDOFF.md for h3 header declutter --- HANDOFF.md | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..b87fc2a --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,173 @@ +# H3 — Header Declutter + +Branch: `h3/header-declutter` (branched off `dev`) +Worktree: `/home/tradam/projects/dispatch/h3-header-declutter` + +## Summary + +The app header used to be: **`Dispatch | … | Connection · Copy · Theme · Sidebar`** — four right-aligned buttons, two of them unrelated to navigation. + +It is now: **`Dispatch | … | Connection · Sidebar`**. + +The two removed buttons moved into the sidebar where the rest of the app's +controls already live: + +| Button | Old location | New location | +| ---------- | -------------------------- | ---------------------------------------------- | +| **Copy** | Header | New **Debug** sidebar panel | +| **Theme** | Header → `ThemeSwitcher` modal | Inlined as a section in the **Settings** sidebar panel | + +`ThemeSwitcher.svelte` was deleted; its theme list + apply-and-persist logic +was inlined into `SettingsPanel.svelte` (a sidebar panel doesn't need a +modal, and Settings already owns all other UI preferences). + +## Files + +### Modified +- `packages/frontend/src/lib/components/Header.svelte` — removed Copy + button, Theme button, ThemeSwitcher import, `showThemeSwitcher` state, + `copyLabel` state, `handleCopy` / `resetCopyLabel` helpers, and the + `{#if showThemeSwitcher}` block. Only the Dispatch title (left), + connection status indicator, and Sidebar toggle (right) remain. +- `packages/frontend/src/lib/components/SidebarPanel.svelte` — registered + `"Debug"` as a new entry in `viewOptions` (last in the list) and added + the corresponding `{:else if panel.selected === "Debug"} ` + branch. Imported `DebugPanel`. +- `packages/frontend/src/lib/components/SettingsPanel.svelte` — added a + `THEMES` list + `currentTheme` state + `selectTheme` helper at the top + of the script, and a "Theme → Appearance" `` dropdown on any sidebar slot. +- **Removed component**: `ThemeSwitcher` (no other importers; safe). +- **Component prop changes**: none. `Header.svelte`'s only prop + (`onToggleSidebar: () => void`) is unchanged. `SidebarPanel.svelte`, + `SettingsPanel.svelte`, and `DebugPanel.svelte` keep / introduce + prop shapes consistent with neighboring panels. + +## LocalStorage migration + +No migration is needed. + +- `dispatch-theme` localStorage key: unchanged shape, unchanged + consumers (boot apply in `App.svelte:onMount`, write in + `selectTheme`). A user reloading after this branch sees their + previously-selected theme intact. +- `dispatch-sidebar-panels` (the sidebar layout): the existing + `loadSidebarPanels` already filters non-string entries and falls + back to the default layout when nothing valid is stored. Adding + `"Debug"` to `viewOptions` is purely additive: existing users + with stored layouts continue to render exactly what they had + before, and the new option becomes available to anyone who opens + the dropdown. No code change to `sidebar-storage.ts` was required. + +## Verification + +### `bun run check` +``` +$ biome check . +Checked 140 files in 172ms. No fixes applied. +``` +Exit code: 0. + +### `bun run test` +``` +Test Files 24 passed (24) + Tests 393 passed (393) + Start at 09:15:17 + Duration 2.87s +``` +Exit code: 0. (Includes the existing `sidebar-storage.test.ts` 15-test +suite — no test changes were required since storage semantics didn't +change.) + +### `bun run typecheck` (svelte-check) +``` +svelte-check found 0 errors and 0 warnings +``` + +### Build +``` +vite v6.4.2 building for production... +✓ 166 modules transformed. +✓ built in 3.97s +``` + +### Manual UI smoke (programmatic) +Full interactive `dev:frontend` + `dev:api` boot wasn't run from this +CLI environment, but the equivalents were exercised: + +- Production build (`vite build`) succeeds — confirms all components + compile and Svelte's reactivity contracts are satisfied. +- `vite preview` boots and serves the HTML shell on port 4173. +- Static grep over the built bundle confirms the post-refactor wiring: + - `"Debug"` appears in the bundle as a panel option ✓ + - `"Copy conversation"` button label is present ✓ + - `ThemeSwitcher` / `showThemeSwitcher` symbols are absent (count: 0) ✓ + - Theme list (`dracula`, `cyberpunk`, `caramellatte`) and + `dispatch-theme` storage key are bundled into SettingsPanel ✓ + +For a human visual pass: `bun run dev:api` + `bun run dev:frontend`, +then verify: +1. Header has only `Dispatch | … | Connection · Sidebar`. +2. Add a sidebar slot via `+`, choose "Settings" → "Theme" section + appears at the top with a working ``, not a vertical menu.** The original + `ThemeSwitcher` used a menu of buttons because it was a modal with + the room to display all 13 themes at once. Inside the sidebar a + ` + Enable notifications + + + + + + +
+ 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 29bdd00f946d75671bea7c4b534d32197e5b4b55 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Mon, 1 Jun 2026 09:29:51 +0900 Subject: docs: add HANDOFF.md for ntfy notifications feature --- HANDOFF.md | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 249 insertions(+) create mode 100644 HANDOFF.md diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..ac025b9 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,249 @@ +# Handoff — n2/ntfy-notifications + +## Summary + +Adds **ntfy.sh push notifications** to Dispatch: a configurable per-event +notification dispatcher that POSTs to a user-supplied ntfy topic URL when +notable events happen in the running agent process. + +The architecture is intentionally layered so a future transport (email, +Slack webhook, custom backend) plugs in without touching call sites: + +``` +AgentManager.onEvent ─┐ ┌─→ sendNtfy (fetch) + ├─→ NotificationDispatcher.notify(event) +PermissionMgr ────────┘ (filter / dedupe) └─→ (other transports later) +.onPromptAdded +``` + +### Event taxonomy + +The user toggles each one independently in Settings: + +| event | trigger | default | priority | tags | +|-----------------------|--------------------------------------------------------------------------|---------|----------|------------------| +| `turn-completed` | assistant `done` event (one per cleanly-finished turn) | on | 3 | white_check_mark | +| `turn-error` | assistant `error` event (final, after all fallback retries) | on | 4 | rotating_light | +| `permission-required` | `PermissionManager` newly admits a prompt to its pending list | on | 4 | lock | +| `agent-spawned` | `tab-created` for a **top-level user agent** (parent=null, slug present) | off | 2 | sparkles | + +Each notification carries a short tab tag (`tab-`) so multi-tab users +can tell which conversation pinged them. + +### Design notes + +- **Non-blocking**: `dispatcher.notify` does `void Promise.resolve(send(...)).catch(warn)`. + A slow or unreachable ntfy server never stalls a turn. Worst case is a + 10s per-request abort timeout in the transport. +- **Dedupe**: 5 s in-memory window keyed by `dedupeKey`. Used for + `permission-required` because the permission system rebroadcasts the + whole pending list on every change (we'd otherwise re-fire on every + unrelated mutation). +- **Master switch + per-event toggle**: both must allow before a send. + Disabled config is a fast no-op (no fetch, no `loadConfig` work past + the early return). +- **Single global config**: matches the rest of the codebase's settings + table (`perm_*`, `title_model_*` are also global). Stored as one JSON + blob under `settings.key = 'ntfy_config'`. +- **Auth token round-trip**: `GET /notifications` redacts the token but + surfaces `hasAuthToken: boolean`. `PUT /notifications` semantics: + `authToken === undefined` keeps the stored value, `""` clears it, any + other string replaces it. The frontend's "Clear stored token" button + uses the explicit-`""` path. +- **Header injection guard**: CR/LF and control chars are stripped from + `Title`/`Tags` before they go into `fetch` headers. +- **Permission "added" detection**: `PermissionManager.broadcastPending` + now diffs the current pending-id set against an `announcedPromptIds` + set and fires `onPromptAdded` only for genuinely new ids. Resolved ids + are pruned. This keeps the contract "one notification per prompt". + +## Files changed / added + +``` +packages/core/src/notifications/types.ts +97 (new) +packages/core/src/notifications/ntfy.ts +125 (new) +packages/core/src/notifications/config.ts +73 (new) +packages/core/src/notifications/dispatcher.ts +238 (new) +packages/core/src/notifications/index.ts +29 (new) +packages/core/src/index.ts +2 (barrel re-export) +packages/core/tests/notifications/ntfy.test.ts +173 (new, 16 tests) +packages/core/tests/notifications/config.test.ts +130 (new, 10 tests) +packages/core/tests/notifications/dispatcher.test.ts +325 (new, 13 tests) + +packages/api/src/permission-manager.ts ~98 (+ onPromptAdded contract) +packages/api/src/routes/notifications.ts +82 (new — GET/PUT/POST routes) +packages/api/src/app.ts +18 (wire dispatcher + mount routes) +packages/api/tests/permission-manager.test.ts +103 (new, 4 tests) +packages/api/tests/routes.test.ts +51 (add mocks for new core exports) + +packages/frontend/src/lib/components/SettingsPanel.svelte +256 (new ntfy section) +``` + +Three commits on `n2/ntfy-notifications`: + +``` +786bc43 feat(frontend): ntfy.sh settings block in SettingsPanel +21cdb11 feat(api): wire notification dispatcher into app + /notifications routes +5e72191 feat(core): ntfy.sh notification dispatcher module +``` + +## Public surface added + +### New config (persisted in `settings` table) + +- `settings.key = "ntfy_config"` → JSON-serialized `NtfyConfig`: + ```ts + { + enabled: boolean, + topicUrl: string, + authToken: string, + events: { + "turn-completed": boolean, + "turn-error": boolean, + "permission-required": boolean, + "agent-spawned": boolean, + }, + } + ``` + +### New API routes + +- `GET /notifications` → + `{ config: NtfyConfig & { hasAuthToken: boolean }, eventTypes: string[], defaults: NtfyConfig }` + (authToken is always returned as `""`; `hasAuthToken` reflects what's stored) +- `PUT /notifications` → accepts partial `NtfyConfig`. Validates topic URL + when `enabled === true`. Returns the saved (redacted) config or `400`. +- `POST /notifications/test` → sends a `turn-completed`-typed test + notification using the saved config. Returns `{ ok, status?, error? }`, + or `400` if disabled / invalid topic / event-type disabled, or `502` on + ntfy server failure. + +### New core exports (via `@dispatch/core` barrel) + +Types: `NotificationEvent`, `NotificationEventType`, `NtfyConfig`, +`NtfyPriority`, `NtfySendResult`, `FetchLike`, `DispatcherOptions`, +`AgentEventSource`, `PermissionPromptSource`, `TabTitleLookup`. + +Values: `NotificationDispatcher`, `sendNtfy`, `validateTopicUrl`, +`loadNtfyConfig`, `saveNtfyConfig`, `clearNtfyConfig`, +`normalizeNtfyConfig`, `defaultNtfyConfig`, `redactNtfyConfig`, +`NTFY_EVENT_TYPES`, `NTFY_DEFAULT_EVENTS`, `NTFY_DEFAULT_PRIORITIES`, +`NTFY_DEFAULT_TAGS`, `NTFY_CONFIG_KEY`. + +### New API surface on `PermissionManager` + +- `onPromptAdded(listener) => unsubscribe` — fires exactly once per + genuinely-new pending prompt id (with `{ id, permission, description, metadata }`). + +### New exported singleton in `packages/api/src/app.ts` + +- `notificationDispatcher: NotificationDispatcher` — already wired to + the module-level `agentManager` and `permissionManager`. Exposed so + tests / future callers can `dispose()` or `notify(...)` directly. + +### Frontend + +No new exported props — the change is entirely inside `SettingsPanel.svelte` +and uses its existing `{ keys, apiBase }` props. + +## Verification status + +### `bun run check` + +``` +$ biome check . +Checked 150 files in 175ms. No fixes applied. +``` + +✅ Pass (0 errors, 0 warnings). + +### `bun run test` + +``` +Test Files 28 passed (28) + Tests 436 passed (436) + Duration 2.93s +``` + +✅ Pass. Baseline was 393 tests in 24 files; this branch adds 43 tests +across 4 new files (`notifications/ntfy.test.ts` ×16, +`notifications/config.test.ts` ×10, `notifications/dispatcher.test.ts` ×13, +`permission-manager.test.ts` ×4) and modifies 0 existing tests. + +### Per-package strict typecheck + +``` +@dispatch/core tsc --noEmit — 0 errors +@dispatch/api tsc --noEmit — 0 errors +@dispatch/frontend svelte-check — 0 errors, 0 warnings +``` + +### Manual smoke test + +Verified end-to-end against the real `ntfy.sh` server with no auth: + +``` +$ bun -e 'import { sendNtfy } from "./packages/core/src/notifications/ntfy.js"; ...' +Sending to: https://ntfy.sh/dispatch-smoke-ofntnrp4 +{"ok":true,"status":200} +``` + +(Topic was throwaway and only used for this smoke test.) Full UI flow +(Settings → topic URL → Save → Send test → push lands in ntfy app) +was not executed because that requires a live `bun run dev:api` plus +`dev:frontend` plus a phone with the ntfy app — but the same code path +that the "Send test" button exercises is what the smoke test above hit, +and the route logic on top of it is covered by unit tests. + +## Assumptions / known gaps + +Decisions made without product input (the spec said "ask if ambiguous"; +each of these felt unambiguous in the context of Dispatch's current +single-user, single-process design): + +1. **Single global config, not per-user.** The existing `settings` table + is global (e.g. `title_model_*`, `perm_*` — all single-tenant). When + Dispatch grows real multi-tenancy this'll need a `user_id` column and + a load-by-user helper, but that's a much bigger refactor than this + feature. + +2. **Auth token persisted in plain text.** Same as the existing + `credentials` / `api_keys` tables in this DB; SQLite at-rest + encryption isn't a thing in this codebase. Token never leaves the + DB on the read path (`GET /notifications` redacts). + +3. **No rate-limiting or burst grouping** beyond the 5 s permission + dedupe. Notification-worthy events are human-scale infrequent (one + per turn, one per permission prompt). If someone hammers `summon` and + ships 50 user agents in 10 seconds, they'll get 50 pushes — that + matches "agent-spawned is off by default" being the right call. + +4. **No click-URL deep-link to the originating tab.** The frontend + doesn't currently route tabs by URL (`router.svelte.ts` just toggles + between `dashboard` and `agent-builder`), so I left `clickUrl` + plumbing in the transport layer for callers but didn't synthesize + one in the dispatcher. A future "open this tab" router change would + make this a 4-line addition in `buildTurnCompleted` / etc. + +5. **Event taxonomy is intentionally small.** I considered `model-changed` + and `queue-overflow`/`auto-wake-budget-exhausted` notices but they + felt like "annoying push" rather than "useful push"; easy to add + later by extending `NotificationEventType`, `NTFY_DEFAULT_EVENTS`, + and adding a builder + dispatch hook. + +6. **Subagent completions don't notify.** `attachToAgentManager` filters + `agent-spawned` to `parentTabId === null && agentSlug` (top-level user + agents only). `turn-completed`/`turn-error` fire for any tab including + subagents, which is technically what the user asked for (turn + completion) but could be noisy if someone runs a parent agent that + spawns many short-lived subagents. Toggle-off-`turn-completed` is the + escape hatch today; a separate "include subagents" toggle would be a + trivial follow-up. + +7. **Ntfy server-side validation is minimal.** We only check that the + topic URL is a syntactically-valid `http(s)://host/topic`. We don't + ping the server on save (would slow the UI and confuse users behind + captive portals). The "Send test" button is the integration check. + +Working tree is clean; three commits on `n2/ntfy-notifications`; nothing +merged. -- 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