summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src
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/frontend/src
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/frontend/src')
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
2 files changed, 98 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.
+ }
+}