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.
}
}
|