summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/sidebar-storage.ts
blob: 9d9928fc1022066eee0c1810461b2347a92398ae (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
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.
	}
}