diff options
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 15 | ||||
| -rw-r--r-- | packages/frontend/src/lib/sidebar-storage.ts | 84 |
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. + } +} |
