summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
committerAdam Malczewski <[email protected]>2026-06-01 11:44:27 +0900
commit0a5eea4c06371df756aea40f53bb6dbe71df664a (patch)
tree443e454e1edf1814f1a5c8e77507f63812739122 /packages/frontend/src/lib/components
parent00922f6136ff0c6e047bb4a6165682f236971450 (diff)
parent03e58f69e77b7a27e235210158f3f8e499a817c3 (diff)
downloaddispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.tar.gz
dispatch-0a5eea4c06371df756aea40f53bb6dbe71df664a.zip
merge: dev into r1/claude-reset-fix
Brings in the n2/ntfy-notifications feature (ntfy.sh push notifications with per-event toggles, subagent-suppression flag, topic-only input, Settings UI, dispatcher + transport + config modules, 12+ new tests), the header declutter (theme picker + Debug panel moved into Settings / sidebar), the shared theme boot-apply module, and an a11y label for the remove-panel button. No code changes from this branch were touched by the merge — the overlap was purely textual. Conflict resolution: 1. HANDOFF.md (add/add conflict). Both branches independently put a single-purpose HANDOFF.md at the repo root for their respective in-flight feature, matching the existing convention (c351719 did the same for this branch; 29bdd00 did the same for ntfy). After this merge both features ship, so neither is in-flight anymore. Archive both into notes/: - notes/wake-schedule-handoff.md (this branch — git tracks as a rename from HANDOFF.md) - notes/ntfy-notifications-handoff.md (dev — recovered from MERGE_HEAD before deletion) The root HANDOFF.md is intentionally absent post-merge; the next in-flight branch will create its own. 2. packages/api/tests/routes.test.ts (auto-merged). dev appended ntfy stubs to the vi.mock('@dispatch/core', ...) factory; this branch appended a 'Wake schedule routes' describe block at the bottom. The two regions don't overlap and the textual auto-merge is correct (verified: 6 describe blocks, both mock-stub regions and the new describe present, no conflict markers). Verification on the merge commit: bun run test → 31 files, 495 / 495 passing (was 431 on the branch + 64 from dev) bun run check → biome clean, 156 files bun run --cwd packages/frontend typecheck → svelte-check 0 errors, 0 warnings dev can now fast-forward to this commit: git checkout dev && git merge --ff-only r1/claude-reset-fix
Diffstat (limited to 'packages/frontend/src/lib/components')
-rw-r--r--packages/frontend/src/lib/components/DebugPanel.svelte35
-rw-r--r--packages/frontend/src/lib/components/Header.svelte41
-rw-r--r--packages/frontend/src/lib/components/SettingsPanel.svelte328
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte5
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte58
5 files changed, 368 insertions, 99 deletions
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 @@
+<script lang="ts">
+import { tabStore } from "../tabs.svelte.js";
+
+let copyLabel = $state("Copy conversation");
+
+function resetCopyLabel(): void {
+ copyLabel = "Copy conversation";
+}
+
+async function handleCopy(): Promise<void> {
+ const text = tabStore.copyConversation();
+ try {
+ await navigator.clipboard.writeText(text);
+ copyLabel = "Copied";
+ } catch {
+ copyLabel = "Failed";
+ }
+ setTimeout(resetCopyLabel, 1500);
+}
+</script>
+
+<div class="flex flex-col gap-3">
+ <div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Debug</div>
+
+ <div class="flex flex-col gap-2">
+ <p class="text-xs text-base-content/70">Conversation</p>
+ <p class="text-xs text-base-content/40">
+ Copy a structured plain-text dump of the active tab's conversation
+ (chunk shape included) for bug reports.
+ </p>
+ <button type="button" class="btn btn-sm btn-primary w-full" onclick={handleCopy}>
+ {copyLabel}
+ </button>
+ </div>
+</div>
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 @@
<script lang="ts">
import { router } from "../router.svelte.js";
-import { tabStore } from "../tabs.svelte.js";
import { wsClient } from "../ws.svelte.js";
-import ThemeSwitcher from "./ThemeSwitcher.svelte";
const { onToggleSidebar }: { onToggleSidebar: () => void } = $props();
-
-let showThemeSwitcher = $state(false);
-let copyLabel = $state("Copy");
-
-function resetCopyLabel() {
- copyLabel = "Copy";
-}
-
-async function handleCopy() {
- const text = tabStore.copyConversation();
- try {
- await navigator.clipboard.writeText(text);
- copyLabel = "Copied";
- setTimeout(resetCopyLabel, 1500);
- } catch {
- copyLabel = "Failed";
- setTimeout(resetCopyLabel, 1500);
- }
-}
</script>
<header class="navbar bg-base-200 px-4 min-h-14 flex-shrink-0">
@@ -38,22 +17,6 @@ async function handleCopy() {
<button
type="button"
class="btn btn-ghost btn-sm"
- onclick={handleCopy}
- aria-label="Copy conversation"
- >
- {copyLabel}
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
- onclick={() => (showThemeSwitcher = !showThemeSwitcher)}
- aria-label="Switch theme"
- >
- Theme
- </button>
- <button
- type="button"
- class="btn btn-ghost btn-sm"
onclick={onToggleSidebar}
aria-label="Toggle sidebar"
>
@@ -61,7 +24,3 @@ async function handleCopy() {
</button>
</div>
</header>
-
-{#if showThemeSwitcher}
- <ThemeSwitcher onclose={() => (showThemeSwitcher = false)} />
-{/if}
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte
index 392852a..7a810d5 100644
--- a/packages/frontend/src/lib/components/SettingsPanel.svelte
+++ b/packages/frontend/src/lib/components/SettingsPanel.svelte
@@ -1,6 +1,7 @@
<script lang="ts">
import { config } from "../config.js";
import { appSettings } from "../settings.svelte.js";
+import { applyTheme, loadStoredTheme, THEMES, type Theme } from "../theme.js";
import type { KeyInfo } from "../types.js";
const {
@@ -11,6 +12,17 @@ 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. Theme constants and apply/persist live in `../theme.ts`
+// so the boot-time apply in `App.svelte` and this picker can't drift.
+let currentTheme = $state<Theme>(loadStoredTheme());
+
+function selectTheme(theme: Theme): void {
+ currentTheme = theme;
+ applyTheme(theme);
+}
+
let titleKeyId = $state<string | null>(null);
let titleModelId = $state<string | null>(null);
let availableModels = $state<string[]>([]);
@@ -20,6 +32,62 @@ 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;
+ topic: string;
+ authToken: string;
+ hasAuthToken?: boolean;
+ events: Record<NotificationEventType, boolean>;
+ notifySubagents: boolean;
+}
+
+const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = {
+ "turn-completed": "Turn completed",
+ "turn-error": "Turn error",
+ "permission-required": "Permission requested",
+ "agent-spawned": "User agent spawned",
+};
+
+const DEFAULT_NTFY: NtfyConfigView = {
+ enabled: false,
+ topic: "",
+ authToken: "",
+ hasAuthToken: false,
+ events: {
+ "turn-completed": true,
+ "turn-error": true,
+ "permission-required": true,
+ "agent-spawned": false,
+ },
+ notifySubagents: false,
+};
+
+let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } });
+let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save
+let ntfyEventOrder = $state<NotificationEventType[]>([
+ "turn-completed",
+ "turn-error",
+ "permission-required",
+ "agent-spawned",
+]);
+let ntfySaving = $state(false);
+let ntfySaveError = $state<string | null>(null);
+let ntfySaveOk = $state(false);
+let ntfyTesting = $state(false);
+let ntfyTestResult = $state<string | null>(null);
+let ntfyTestOk = $state(false);
+let ntfyClearingToken = $state(false);
+
function onChunkLimitChange(e: Event): void {
const input = e.target as HTMLInputElement;
const val = parseInt(input.value, 10);
@@ -73,6 +141,130 @@ async function loadSettings(): Promise<void> {
} catch {
// ignore
}
+ await loadNtfy();
+}
+
+async function loadNtfy(): Promise<void> {
+ 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<void> {
+ 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<NtfyConfigView> & { authToken?: string } = {
+ enabled: ntfy.enabled,
+ topic: ntfy.topic,
+ events: ntfy.events,
+ notifySubagents: ntfy.notifySubagents,
+ };
+ 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<void> {
+ 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;
+ }
+}
+
+async function clearNtfyAuthToken(): Promise<void> {
+ // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing).
+ // Optimistic local state on failure caused a real bug pre-review: UI showed
+ // the token cleared while the server still held it, then "Save" treated the
+ // blank input as "keep existing" and silently re-armed the old token. Await
+ // the response and only flip local state on success.
+ ntfyClearingToken = true;
+ ntfySaveError = null;
+ try {
+ const res = await fetch(`${apiBase}/notifications`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ authToken: "" }),
+ });
+ const data = (await res.json().catch(() => ({}))) as {
+ config?: NtfyConfigView;
+ error?: string;
+ };
+ if (!res.ok) {
+ ntfySaveError = data.error ?? `Clear failed (HTTP ${res.status})`;
+ return;
+ }
+ ntfyAuthTokenInput = "";
+ if (data.config) {
+ ntfy = {
+ ...DEFAULT_NTFY,
+ ...data.config,
+ events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) },
+ };
+ } else {
+ ntfy = { ...ntfy, hasAuthToken: false };
+ }
+ } catch (e) {
+ ntfySaveError = e instanceof Error ? e.message : "Network error";
+ } finally {
+ ntfyClearingToken = false;
+ }
}
async function toggleAutoExpand(): Promise<void> {
@@ -136,6 +328,22 @@ $effect(() => {
<div class="text-xs font-semibold text-base-content/50 uppercase tracking-wide">Settings</div>
<div class="flex flex-col gap-2">
+ <p class="text-xs text-base-content/70">Theme</p>
+ <label class="text-xs text-base-content/60">
+ Appearance
+ <select
+ class="select select-bordered select-sm w-full capitalize"
+ value={currentTheme}
+ onchange={(e) => selectTheme(e.currentTarget.value as Theme)}
+ >
+ {#each THEMES as theme (theme)}
+ <option value={theme} class="capitalize">{theme}</option>
+ {/each}
+ </select>
+ </label>
+
+ <div class="divider my-0"></div>
+
<p class="text-xs text-base-content/70">Title Generation Model</p>
<p class="text-xs text-base-content/40">Used to generate short titles for new tabs after the first message.</p>
@@ -222,5 +430,125 @@ $effect(() => {
{#if backendUrlSaved}
<p class="text-xs text-success">Saved. Reload the page to apply.</p>
{/if}
+
+ <div class="divider my-0"></div>
+
+ <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p>
+ <p class="text-xs text-base-content/40">
+ Push notifications to your phone when things happen here. Subscribe to your topic in the
+ <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them.
+ </p>
+
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.enabled}
+ />
+ <span class="text-xs text-base-content/70">Enable notifications</span>
+ </label>
+
+ <label class="text-xs text-base-content/60 flex flex-col gap-1">
+ Topic
+ <input
+ type="text"
+ class="input input-bordered input-sm w-full"
+ placeholder="your-secret-topic"
+ bind:value={ntfy.topic}
+ />
+ <span class="text-[10px] text-base-content/40">
+ Any string — pick something unguessable, since anyone with the topic name can read your notifications. Subscribe to the same topic in the ntfy app.
+ </span>
+ </label>
+
+ <label class="text-xs text-base-content/60 flex flex-col gap-1">
+ Auth token (optional, for private ntfy servers)
+ <input
+ type="password"
+ class="input input-bordered input-sm w-full"
+ placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"}
+ bind:value={ntfyAuthTokenInput}
+ autocomplete="off"
+ />
+ {#if ntfy.hasAuthToken}
+ <button
+ type="button"
+ class="btn btn-xs btn-ghost btn-outline self-start"
+ disabled={ntfyClearingToken}
+ onclick={clearNtfyAuthToken}
+ >
+ {#if ntfyClearingToken}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Clear stored token
+ {/if}
+ </button>
+ {/if}
+ </label>
+
+ <div class="flex flex-col gap-1 mt-1">
+ <span class="text-xs text-base-content/60">Notify me on:</span>
+ {#each ntfyEventOrder as evType (evType)}
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.events[evType]}
+ />
+ <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span>
+ </label>
+ {/each}
+ </div>
+
+ <div class="flex flex-col gap-1 mt-1">
+ <label class="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ class="checkbox checkbox-sm rounded-sm"
+ bind:checked={ntfy.notifySubagents}
+ />
+ <span class="text-xs text-base-content/70">Include subagent tabs</span>
+ </label>
+ <span class="text-[10px] text-base-content/40 pl-6">
+ Off (default): turn-completed/turn-error from subagents are suppressed. Permission prompts still fire so subagents don't silently hang.
+ </span>
+ </div>
+
+ <div class="flex gap-1 mt-1">
+ <button
+ type="button"
+ class="btn btn-sm btn-primary flex-1"
+ disabled={ntfySaving}
+ onclick={saveNtfy}
+ >
+ {#if ntfySaving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Save
+ {/if}
+ </button>
+ <button
+ type="button"
+ class="btn btn-sm btn-outline"
+ disabled={ntfyTesting || !ntfy.enabled}
+ onclick={sendNtfyTest}
+ title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"}
+ >
+ {#if ntfyTesting}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Send test
+ {/if}
+ </button>
+ </div>
+ {#if ntfySaveOk}
+ <p class="text-xs text-success">Saved.</p>
+ {/if}
+ {#if ntfySaveError}
+ <p class="text-xs text-error">{ntfySaveError}</p>
+ {/if}
+ {#if ntfyTestResult}
+ <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p>
+ {/if}
</div>
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 206ed09..491b1bd 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() {
@@ -137,6 +139,7 @@ function contentClass(_selected: string): string {
<button
type="button"
class="btn btn-sm btn-ghost btn-square shrink-0"
+ aria-label="Remove panel"
onclick={() => {
panels = panels.filter((p) => p.id !== panel.id);
}}
@@ -181,6 +184,8 @@ function contentClass(_selected: string): string {
<ToolPermissions entries={permissionLog} {apiBase} />
{:else if panel.selected === "Settings"}
<SettingsPanel {keys} {apiBase} />
+ {:else if panel.selected === "Debug"}
+ <DebugPanel />
{/if}
</div>
</div>
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 @@
-<script lang="ts">
-const THEMES = [
- "light",
- "dark",
- "dracula",
- "night",
- "nord",
- "sunset",
- "cyberpunk",
- "forest",
- "cmyk",
- "coffee",
- "caramellatte",
- "garden",
- "luxury",
-] as const;
-
-const STORAGE_KEY = "dispatch-theme";
-
-const { onclose }: { onclose: () => void } = $props();
-
-let currentTheme = $state(
- (typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY)) || "dark",
-);
-
-let dialogEl: HTMLDialogElement | undefined = $state();
-
-$effect(() => {
- if (dialogEl && !dialogEl.open) dialogEl.showModal();
-});
-
-function selectTheme(theme: string) {
- currentTheme = theme;
- document.documentElement.setAttribute("data-theme", theme);
- localStorage.setItem(STORAGE_KEY, theme);
- onclose();
-}
-</script>
-
-<dialog class="modal" bind:this={dialogEl} oncancel={onclose}>
- <div class="modal-box w-56">
- <h3 class="text-sm font-semibold mb-3">Select Theme</h3>
- <ul class="menu menu-sm">
- {#each THEMES as theme}
- <li>
- <button
- type="button"
- class="capitalize {currentTheme === theme ? 'menu-active' : ''}"
- onclick={() => selectTheme(theme)}
- >
- {theme}
- </button>
- </li>
- {/each}
- </ul>
- </div>
- <form method="dialog" class="modal-backdrop"><button>close</button></form>
-</dialog>