summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/lib')
-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.
+ }
+}