summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 11:28:52 +0900
committerAdam Malczewski <[email protected]>2026-06-01 11:28:52 +0900
commit03e58f69e77b7a27e235210158f3f8e499a817c3 (patch)
treedd64b3027dcb06cc62b413340a3dbf21423671b8
parentd3d94b69a9f98a0a4b68dc6ee830466471adf26d (diff)
parent76eb992577c084d9d9bba2fdf5e2c8317fd59bcd (diff)
downloaddispatch-03e58f69e77b7a27e235210158f3f8e499a817c3.tar.gz
dispatch-03e58f69e77b7a27e235210158f3f8e499a817c3.zip
Merge branch 'dev' into n2/ntfy-notifications
Brings in: theme picker consolidation, sidebar Debug panel, header declutter, a11y label on remove-panel button. Conflicts in SettingsPanel.svelte (theme picker insertion site overlaps the ntfy block) and HANDOFF.md (each branch maintains its own). # Conflicts: # HANDOFF.md
-rw-r--r--.gitignore3
-rw-r--r--packages/frontend/src/App.svelte13
-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.svelte28
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte5
-rw-r--r--packages/frontend/src/lib/components/ThemeSwitcher.svelte58
-rw-r--r--packages/frontend/src/lib/theme.ts92
-rw-r--r--packages/frontend/tests/theme.test.ts144
9 files changed, 313 insertions, 106 deletions
diff --git a/.gitignore b/.gitignore
index 51c3006..960e309 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,6 @@ packaging/electron/pkg/
packaging/electron/*.pkg.tar.zst
packaging/electron/*.tar.zst
packages/frontend/release/
+
+# Code-review artifacts
+claude-report.md
diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte
index 1bae000..eaa28e8 100644
--- a/packages/frontend/src/App.svelte
+++ b/packages/frontend/src/App.svelte
@@ -11,11 +11,10 @@ import TabBar from "./lib/components/TabBar.svelte";
import { config } from "./lib/config.js";
import { router } from "./lib/router.svelte.js";
import { tabStore } from "./lib/tabs.svelte.js";
+import { applyTheme, loadStoredTheme } from "./lib/theme.js";
import type { KeyInfo } from "./lib/types.js";
import { wsClient } from "./lib/ws.svelte.js";
-const STORAGE_KEY = "dispatch-theme";
-
let modelsData = $state<{ keys: KeyInfo[] }>({
keys: [],
});
@@ -76,11 +75,11 @@ $effect(() => {
});
onMount(() => {
- // Apply saved theme
- const saved = localStorage.getItem(STORAGE_KEY);
- if (saved) {
- document.documentElement.setAttribute("data-theme", saved);
- }
+ // Apply persisted theme (or the shared DEFAULT_THEME if nothing is
+ // stored) so the first paint matches what the Settings panel will
+ // show as the selected option. Without this, daisyUI falls back to
+ // the first theme in `app.css` (light) while Settings shows "dark".
+ applyTheme(loadStoredTheme());
// Connect WebSocket in parallel with hydration. The `statuses`
// snapshot delivered on WS open is idempotent against
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 dc9d07d..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[]>([]);
@@ -316,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>
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>
diff --git a/packages/frontend/src/lib/theme.ts b/packages/frontend/src/lib/theme.ts
new file mode 100644
index 0000000..2b4bad0
--- /dev/null
+++ b/packages/frontend/src/lib/theme.ts
@@ -0,0 +1,92 @@
+/**
+ * Single source of truth for the app's theme picker.
+ *
+ * Two callers care about themes:
+ * - `App.svelte`'s `onMount`, which applies the persisted theme on boot
+ * so the first paint is the right color.
+ * - `SettingsPanel.svelte`'s theme `<select>`, which lets the user
+ * change theme at runtime.
+ *
+ * Both used to hand-roll their own `localStorage` key, default value,
+ * and DOM-attribute write. That drift produced a real bug: `App.svelte`
+ * left the DOM untouched on first load (so daisyUI fell back to the
+ * first theme in `app.css`, `light`), while `SettingsPanel` showed
+ * `"dark"` as the selected value — UI and reality disagreed until the
+ * user manually picked a theme. Centralizing here closes that gap.
+ *
+ * The theme list also intentionally mirrors the `@plugin "daisyui"`
+ * block in `app.css`. Drift between the two is harmless (a theme name
+ * present here but not in CSS just falls back to the default daisyUI
+ * theme at render time), but they should be kept in sync by hand —
+ * daisyUI's plugin config is a CSS-time concern and can't be imported
+ * from TS.
+ */
+
+export const THEMES = [
+ "light",
+ "dark",
+ "dracula",
+ "night",
+ "nord",
+ "sunset",
+ "cyberpunk",
+ "forest",
+ "cmyk",
+ "coffee",
+ "caramellatte",
+ "garden",
+ "luxury",
+] as const;
+
+export type Theme = (typeof THEMES)[number];
+
+export const THEME_STORAGE_KEY = "dispatch-theme";
+
+/**
+ * The fallback theme used both at first-boot apply (when nothing is
+ * persisted) and as the UI's default-selected value. They MUST match —
+ * if they ever diverged again, the UI would show one theme and the
+ * page would render another.
+ */
+export const DEFAULT_THEME: Theme = "dark";
+
+/**
+ * Read the persisted theme. Returns `DEFAULT_THEME` when nothing is
+ * stored, when access throws (private mode / SecurityError), or when
+ * the stored value isn't one of the known themes. Never throws.
+ *
+ * SSR-safe: if `localStorage` is undefined (e.g. `vite build` during
+ * prerender, if that's ever wired up), returns the default.
+ */
+export function loadStoredTheme(): Theme {
+ try {
+ if (typeof localStorage === "undefined") return DEFAULT_THEME;
+ const raw = localStorage.getItem(THEME_STORAGE_KEY);
+ if (raw && (THEMES as readonly string[]).includes(raw)) {
+ return raw as Theme;
+ }
+ return DEFAULT_THEME;
+ } catch {
+ return DEFAULT_THEME;
+ }
+}
+
+/**
+ * Apply a theme to the live document and persist it. The DOM write is
+ * unconditional (daisyUI keys off `data-theme` on the `<html>`
+ * element); the storage write is best-effort and swallows quota /
+ * SecurityError failures because the session continues to work either
+ * way — only cross-reload persistence degrades.
+ */
+export function applyTheme(theme: Theme): void {
+ if (typeof document !== "undefined") {
+ document.documentElement.setAttribute("data-theme", theme);
+ }
+ try {
+ if (typeof localStorage !== "undefined") {
+ localStorage.setItem(THEME_STORAGE_KEY, theme);
+ }
+ } catch {
+ // Best-effort.
+ }
+}
diff --git a/packages/frontend/tests/theme.test.ts b/packages/frontend/tests/theme.test.ts
new file mode 100644
index 0000000..63a343d
--- /dev/null
+++ b/packages/frontend/tests/theme.test.ts
@@ -0,0 +1,144 @@
+/**
+ * Tests for `src/lib/theme.ts` — the shared theme picker module.
+ *
+ * Covers the post-Gemini-review fix where `App.svelte` (boot apply)
+ * and `SettingsPanel.svelte` (UI picker) used to hand-roll their own
+ * defaults and could disagree. After consolidation, both call into
+ * this module and the bug class is gone — these tests pin that
+ * invariant.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { applyTheme, DEFAULT_THEME, loadStoredTheme, THEMES } from "../src/lib/theme.js";
+
+const LS_KEY = "dispatch-theme";
+
+function makeLocalStorageMock(): Storage {
+ const store = new Map<string, string>();
+ return {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ },
+ clear: () => {
+ store.clear();
+ },
+ get length() {
+ return store.size;
+ },
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
+ };
+}
+
+function makeDocumentMock(): { documentElement: { setAttribute: (k: string, v: string) => void } } {
+ const attrs = new Map<string, string>();
+ return {
+ documentElement: {
+ setAttribute: (k: string, v: string) => {
+ attrs.set(k, v);
+ },
+ // expose for assertions
+ // @ts-expect-error — test-only escape hatch
+ _attrs: attrs,
+ },
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", makeLocalStorageMock());
+ vi.stubGlobal("document", makeDocumentMock());
+});
+
+describe("DEFAULT_THEME", () => {
+ it("is one of the THEMES (sanity check that they can't drift)", () => {
+ expect((THEMES as readonly string[]).includes(DEFAULT_THEME)).toBe(true);
+ });
+});
+
+describe("loadStoredTheme", () => {
+ it("returns DEFAULT_THEME when localStorage is empty (first-ever load)", () => {
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns the stored theme when it's a known theme", () => {
+ localStorage.setItem(LS_KEY, "dracula");
+ expect(loadStoredTheme()).toBe("dracula");
+ });
+
+ it("returns DEFAULT_THEME when the stored value isn't a known theme", () => {
+ // Guards against a stale storage entry from a removed theme,
+ // or a hand-edited bad value, falling through to daisyUI's own
+ // fallback (which is `light`, not our DEFAULT).
+ localStorage.setItem(LS_KEY, "solarized-rainbow");
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage.getItem throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("SecurityError");
+ },
+ setItem: () => {},
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+
+ it("returns DEFAULT_THEME when localStorage is undefined (SSR)", () => {
+ vi.stubGlobal("localStorage", undefined);
+ expect(loadStoredTheme()).toBe(DEFAULT_THEME);
+ });
+});
+
+describe("applyTheme", () => {
+ it("writes data-theme on the document element", () => {
+ applyTheme("nord");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("nord");
+ });
+
+ it("persists the theme to localStorage", () => {
+ applyTheme("forest");
+ expect(localStorage.getItem(LS_KEY)).toBe("forest");
+ });
+
+ it("round-trips through loadStoredTheme", () => {
+ applyTheme("luxury");
+ expect(loadStoredTheme()).toBe("luxury");
+ });
+
+ it("does not throw when localStorage.setItem throws (quota etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(() => applyTheme("cyberpunk")).not.toThrow();
+ });
+
+ it("still writes to the DOM even if localStorage write throws", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ applyTheme("coffee");
+ // @ts-expect-error — test mock exposes `_attrs`
+ expect(document.documentElement._attrs.get("data-theme")).toBe("coffee");
+ });
+});