summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 08:41:33 +0900
committerAdam Malczewski <[email protected]>2026-05-28 08:41:33 +0900
commit6662622e1c89fa1124b7342f136ab5f8d3c97972 (patch)
tree71a35186476e14af0ccaa1c11be24953e518a08e /packages
parentd2e2e67425e5106025ee8082a0768989b5de814f (diff)
downloaddispatch-6662622e1c89fa1124b7342f136ab5f8d3c97972.tar.gz
dispatch-6662622e1c89fa1124b7342f136ab5f8d3c97972.zip
feat(frontend): persist sidebar panel layout across browser refreshes via localStorage
Carries the in-app sidebar layout (which views are open and in what order) across page reloads. Closes the natural follow-up to the tab- restore feature in d2e2e67: tabs survive, but until now the sidebar panels (Chat Settings / Tasks / Skills / Tools / etc.) reset to a single default panel on every load. Scope (explicitly bounded by the user): - Persistence target: localStorage. Matches the precedent for UI preferences (`dispatch-theme`, `dispatch-api-url`). Per-device layout; no backend round-trip. - sidebarOpen (the Header button that hides the whole sidebar column) is NOT persisted; always starts open on every load. - No drag-to-reorder UI added — persistence captures whatever order the user established via the existing add/remove buttons. Implementation: • New `packages/frontend/src/lib/sidebar-storage.ts` — pure functions `loadSidebarPanels(): string[]` and `saveSidebarPanels(selected: string[]): void`. localStorage key is `dispatch-sidebar-panels` (canonical `dispatch-` prefix). `loadSidebarPanels` is defensive against every failure mode (missing key, malformed JSON, non-array root, non-string entries, empty-after-filter, localStorage.getItem throwing under SecurityError). Returns a fresh array on every call so mutations by the caller don't pollute the module-level default constant. `saveSidebarPanels` swallows storage errors (quota / disabled / SecurityError) — best-effort. • `packages/frontend/src/lib/components/SidebarPanel.svelte`: seed the `panels: $state` from `loadSidebarPanels().map(s => ({ id: nextId++, selected: s }))` and add a `$effect` that calls `saveSidebarPanels(panels.map(p => p.selected))` whenever `panels` changes. The session-ephemeral `id` field is regenerated on every mount; only the `selected` strings round-trip. • Existing addPanel / remove / dropdown handlers untouched — they all reassign `panels` (`panels = [...panels, ...]`, `panels = panels.filter(...)`, `panels = panels.map(...)`), which triggers the new $effect. The minimum-one-panel invariant (X button hidden on idx 0) is preserved at the UI layer and reinforced by the loader's empty-fallback to the default layout. Tests: 15 new in `packages/frontend/tests/sidebar-storage.test.ts` — load with empty / valid / malformed / non-array / null / mixed-type / empty-after-filter / throwing-getItem; save round-trip; save error swallowing; overwrite semantics; empty-save / load-fallback; mutation isolation. Frontend total: 59 tests (was 44; +15). API 31, core 168 unchanged. Typecheck clean (svelte-check 0 errors), biome clean (126 files). Gemini code review (yolo mode, prompt-level write restriction to report.md only): SHIP, no findings.
Diffstat (limited to 'packages')
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
-rw-r--r--packages/frontend/tests/sidebar-storage.test.ts148
3 files changed, 246 insertions, 1 deletions
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index 9ab6dde..e68781f 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -1,4 +1,5 @@
<script lang="ts">
+import { loadSidebarPanels, saveSidebarPanels } from "../sidebar-storage.js";
import type { KeyInfo, LogEntry, TaskItem } from "../types.js";
import ClaudeReset from "./ClaudeReset.svelte";
import ConfigPanel from "./ConfigPanel.svelte";
@@ -58,8 +59,20 @@ interface Panel {
selected: string;
}
+// The `id` field is purely a stable key for Svelte's `{#each ... (panel.id)}`
+// block within a single session — it is NEVER persisted. Only the ordered
+// list of `selected` strings is round-tripped through localStorage; ids are
+// regenerated fresh from `nextId` on every mount.
let nextId = 0;
-let panels = $state<Panel[]>([{ id: nextId++, selected: "Chat Settings" }]);
+let panels = $state<Panel[]>(loadSidebarPanels().map((selected) => ({ id: nextId++, selected })));
+
+// Persist the layout whenever it changes. `$effect` re-runs whenever any
+// reactive read inside it changes; we read `panels` (the whole array) via
+// `.map`, which Svelte 5 tracks. Save errors are swallowed inside
+// `saveSidebarPanels` — best-effort.
+$effect(() => {
+ saveSidebarPanels(panels.map((p) => p.selected));
+});
const viewOptions = [
"Select a view",
diff --git a/packages/frontend/src/lib/sidebar-storage.ts b/packages/frontend/src/lib/sidebar-storage.ts
new file mode 100644
index 0000000..9d9928f
--- /dev/null
+++ b/packages/frontend/src/lib/sidebar-storage.ts
@@ -0,0 +1,84 @@
+/**
+ * LocalStorage persistence for the sidebar panel layout.
+ *
+ * The sidebar (`SidebarPanel.svelte`) is a variable-length stack of
+ * "slot" components; each slot has a dropdown that picks one of N view
+ * components to render (Chat Settings, Tasks, Skills, etc.). The
+ * source-of-truth `panels` array in the component records the order
+ * and selection of each slot. This module persists that array's
+ * `selected` field across browser refreshes/loads.
+ *
+ * Why localStorage and not the backend `settings` table:
+ * - The sidebar layout is a UI preference, not domain state. It
+ * matches the precedent set by `dispatch-theme` and
+ * `dispatch-api-url`, both of which are localStorage.
+ * - Per-device layout is reasonable (a phone may want different
+ * panels than a desktop).
+ * - No backend round-trip on every layout change.
+ *
+ * Why only the `selected` strings and not the full `Panel` objects:
+ * - The `id` field is a session-ephemeral counter
+ * (`SidebarPanel.svelte:61`) that exists only to keep Svelte's
+ * `{#each ... (panel.id)}` block keyed across reorders. Restoring
+ * ids verbatim would have no benefit, and would collide with the
+ * module-scoped `nextId` counter on remount.
+ * - The `selected` string is everything we need to reconstruct the
+ * visible layout.
+ */
+
+const LS_KEY = "dispatch-sidebar-panels";
+
+/**
+ * The fallback layout when nothing is stored or the stored value is
+ * unusable. Matches the hardcoded initial state at
+ * `SidebarPanel.svelte:62` so first-ever load is unchanged: a single
+ * "Chat Settings" panel.
+ */
+const DEFAULT_LAYOUT: ReadonlyArray<string> = ["Chat Settings"];
+
+/**
+ * Read the persisted sidebar layout. Returns an array of
+ * `panel.selected` strings in render order (top-to-bottom).
+ *
+ * Falls back to `DEFAULT_LAYOUT` on any of:
+ * - localStorage key absent (first-ever load)
+ * - JSON.parse throws (corrupt write from a prior session)
+ * - parsed value is not an array (someone hand-edited storage)
+ * - parsed array, after filtering non-string entries, is empty
+ * (preserves the "minimum one panel" invariant the UI enforces
+ * via `{#if idx > 0}` on the remove button)
+ *
+ * Never throws.
+ */
+export function loadSidebarPanels(): string[] {
+ try {
+ const raw = localStorage.getItem(LS_KEY);
+ if (!raw) return [...DEFAULT_LAYOUT];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [...DEFAULT_LAYOUT];
+ // Discard any non-string entries — they could only come from a
+ // hand-edited localStorage or a future schema mismatch. Either
+ // way, we don't want to render `undefined` as a panel name.
+ const cleaned = parsed.filter((x): x is string => typeof x === "string");
+ return cleaned.length > 0 ? cleaned : [...DEFAULT_LAYOUT];
+ } catch {
+ // localStorage access threw (SecurityError in restricted browser
+ // contexts, etc.). Fall through to default.
+ return [...DEFAULT_LAYOUT];
+ }
+}
+
+/**
+ * Persist the current sidebar layout. Best-effort: errors are
+ * swallowed (quota exceeded, localStorage disabled in private mode,
+ * SecurityError, etc.). The next save attempt may succeed; even if
+ * none do, the current session continues to work — only the cross-
+ * reload restore is degraded.
+ */
+export function saveSidebarPanels(selected: string[]): void {
+ try {
+ localStorage.setItem(LS_KEY, JSON.stringify(selected));
+ } catch {
+ // Best-effort.
+ }
+}
diff --git a/packages/frontend/tests/sidebar-storage.test.ts b/packages/frontend/tests/sidebar-storage.test.ts
new file mode 100644
index 0000000..76815b3
--- /dev/null
+++ b/packages/frontend/tests/sidebar-storage.test.ts
@@ -0,0 +1,148 @@
+/**
+ * Tests for `src/lib/sidebar-storage.ts` — the localStorage round-trip
+ * for the sidebar panel layout (`panels[].selected`).
+ *
+ * Bun's `localStorage` shim is partial (`getItem` is missing on a fresh
+ * `globalThis`), so we install a clean in-memory polyfill per-test
+ * rather than relying on whatever environment-default exists.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { loadSidebarPanels, saveSidebarPanels } from "../src/lib/sidebar-storage.js";
+
+const LS_KEY = "dispatch-sidebar-panels";
+
+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,
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", makeLocalStorageMock());
+});
+
+describe("loadSidebarPanels", () => {
+ it("returns the default single-panel layout when localStorage is empty", () => {
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the parsed array when valid JSON is stored", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", "Skills", "Tools"]));
+ expect(loadSidebarPanels()).toEqual(["Tasks", "Skills", "Tools"]);
+ });
+
+ it("preserves order across the round-trip (storage is render-order)", () => {
+ const layout = ["Settings", "Chat Settings", "Key Usage", "Config"];
+ localStorage.setItem(LS_KEY, JSON.stringify(layout));
+ expect(loadSidebarPanels()).toEqual(layout);
+ });
+
+ it("returns the default when the stored JSON is malformed", () => {
+ localStorage.setItem(LS_KEY, "not valid json {[");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when the stored value is a non-array JSON value", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify({ panels: ["Tasks"] }));
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when the stored value is a JSON null", () => {
+ localStorage.setItem(LS_KEY, "null");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("filters out non-string array entries while keeping the valid ones", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", 42, null, "Skills", true, undefined]));
+ expect(loadSidebarPanels()).toEqual(["Tasks", "Skills"]);
+ });
+
+ it("returns the default when filtering leaves an empty array", () => {
+ // Preserves the SidebarPanel "minimum one panel" invariant (the
+ // remove-button is hidden on `idx === 0` so the UI can't drop
+ // below one panel; load must match).
+ localStorage.setItem(LS_KEY, JSON.stringify([1, 2, false, null]));
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when localStorage.getItem throws (SecurityError etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("SecurityError: storage disabled");
+ },
+ setItem: () => {},
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns a fresh array on each call (callers can mutate the result safely)", () => {
+ const a = loadSidebarPanels();
+ const b = loadSidebarPanels();
+ expect(a).toEqual(b);
+ expect(a).not.toBe(b);
+ a.push("Tasks");
+ expect(b).toEqual(["Chat Settings"]);
+ });
+});
+
+describe("saveSidebarPanels", () => {
+ it("writes the array as JSON under the canonical key", () => {
+ saveSidebarPanels(["Chat Settings", "Tasks"]);
+ const raw = localStorage.getItem(LS_KEY);
+ expect(raw).toBe(JSON.stringify(["Chat Settings", "Tasks"]));
+ });
+
+ it("round-trips through load to recover the same value", () => {
+ const layout = ["Chat Settings", "Skills", "Tools", "Config"];
+ saveSidebarPanels(layout);
+ expect(loadSidebarPanels()).toEqual(layout);
+ });
+
+ it("silently ignores storage errors (quota exceeded, SecurityError, etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(() => saveSidebarPanels(["Tasks"])).not.toThrow();
+ });
+
+ it("overwrites a prior layout (no append semantics)", () => {
+ saveSidebarPanels(["Tasks", "Skills"]);
+ saveSidebarPanels(["Config"]);
+ expect(loadSidebarPanels()).toEqual(["Config"]);
+ });
+
+ it("can save an empty array (load will fall back to default on next read)", () => {
+ // We don't refuse empty saves at the save site — the layout
+ // component enforces the minimum-one-panel invariant by hiding
+ // the remove-button on idx 0. If somehow an empty array is
+ // passed, we store it; load substitutes the default on read.
+ saveSidebarPanels([]);
+ expect(localStorage.getItem(LS_KEY)).toBe("[]");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+});