summaryrefslogtreecommitdiffhomepage
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
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.
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
-rw-r--r--packages/frontend/src/lib/sidebar-storage.ts84
-rw-r--r--packages/frontend/tests/sidebar-storage.test.ts148
-rw-r--r--report.md39
4 files changed, 269 insertions, 17 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.
+ }
+}
diff --git a/packages/frontend/tests/sidebar-storage.test.ts b/packages/frontend/tests/sidebar-storage.test.ts
new file mode 100644
index 0000000..76815b3
--- /dev/null
+++ b/packages/frontend/tests/sidebar-storage.test.ts
@@ -0,0 +1,148 @@
+/**
+ * Tests for `src/lib/sidebar-storage.ts` — the localStorage round-trip
+ * for the sidebar panel layout (`panels[].selected`).
+ *
+ * Bun's `localStorage` shim is partial (`getItem` is missing on a fresh
+ * `globalThis`), so we install a clean in-memory polyfill per-test
+ * rather than relying on whatever environment-default exists.
+ */
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { loadSidebarPanels, saveSidebarPanels } from "../src/lib/sidebar-storage.js";
+
+const LS_KEY = "dispatch-sidebar-panels";
+
+function makeLocalStorageMock(): Storage {
+ const store = new Map<string, string>();
+ return {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ },
+ clear: () => {
+ store.clear();
+ },
+ get length() {
+ return store.size;
+ },
+ key: (i: number) => Array.from(store.keys())[i] ?? null,
+ };
+}
+
+beforeEach(() => {
+ vi.stubGlobal("localStorage", makeLocalStorageMock());
+});
+
+describe("loadSidebarPanels", () => {
+ it("returns the default single-panel layout when localStorage is empty", () => {
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the parsed array when valid JSON is stored", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", "Skills", "Tools"]));
+ expect(loadSidebarPanels()).toEqual(["Tasks", "Skills", "Tools"]);
+ });
+
+ it("preserves order across the round-trip (storage is render-order)", () => {
+ const layout = ["Settings", "Chat Settings", "Key Usage", "Config"];
+ localStorage.setItem(LS_KEY, JSON.stringify(layout));
+ expect(loadSidebarPanels()).toEqual(layout);
+ });
+
+ it("returns the default when the stored JSON is malformed", () => {
+ localStorage.setItem(LS_KEY, "not valid json {[");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when the stored value is a non-array JSON value", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify({ panels: ["Tasks"] }));
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when the stored value is a JSON null", () => {
+ localStorage.setItem(LS_KEY, "null");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("filters out non-string array entries while keeping the valid ones", () => {
+ localStorage.setItem(LS_KEY, JSON.stringify(["Tasks", 42, null, "Skills", true, undefined]));
+ expect(loadSidebarPanels()).toEqual(["Tasks", "Skills"]);
+ });
+
+ it("returns the default when filtering leaves an empty array", () => {
+ // Preserves the SidebarPanel "minimum one panel" invariant (the
+ // remove-button is hidden on `idx === 0` so the UI can't drop
+ // below one panel; load must match).
+ localStorage.setItem(LS_KEY, JSON.stringify([1, 2, false, null]));
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns the default when localStorage.getItem throws (SecurityError etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => {
+ throw new Error("SecurityError: storage disabled");
+ },
+ setItem: () => {},
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+
+ it("returns a fresh array on each call (callers can mutate the result safely)", () => {
+ const a = loadSidebarPanels();
+ const b = loadSidebarPanels();
+ expect(a).toEqual(b);
+ expect(a).not.toBe(b);
+ a.push("Tasks");
+ expect(b).toEqual(["Chat Settings"]);
+ });
+});
+
+describe("saveSidebarPanels", () => {
+ it("writes the array as JSON under the canonical key", () => {
+ saveSidebarPanels(["Chat Settings", "Tasks"]);
+ const raw = localStorage.getItem(LS_KEY);
+ expect(raw).toBe(JSON.stringify(["Chat Settings", "Tasks"]));
+ });
+
+ it("round-trips through load to recover the same value", () => {
+ const layout = ["Chat Settings", "Skills", "Tools", "Config"];
+ saveSidebarPanels(layout);
+ expect(loadSidebarPanels()).toEqual(layout);
+ });
+
+ it("silently ignores storage errors (quota exceeded, SecurityError, etc.)", () => {
+ vi.stubGlobal("localStorage", {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => {},
+ clear: () => {},
+ length: 0,
+ key: () => null,
+ });
+ expect(() => saveSidebarPanels(["Tasks"])).not.toThrow();
+ });
+
+ it("overwrites a prior layout (no append semantics)", () => {
+ saveSidebarPanels(["Tasks", "Skills"]);
+ saveSidebarPanels(["Config"]);
+ expect(loadSidebarPanels()).toEqual(["Config"]);
+ });
+
+ it("can save an empty array (load will fall back to default on next read)", () => {
+ // We don't refuse empty saves at the save site — the layout
+ // component enforces the minimum-one-panel invariant by hiding
+ // the remove-button on idx 0. If somehow an empty array is
+ // passed, we store it; load substitutes the default on read.
+ saveSidebarPanels([]);
+ expect(localStorage.getItem(LS_KEY)).toBe("[]");
+ expect(loadSidebarPanels()).toEqual(["Chat Settings"]);
+ });
+});
diff --git a/report.md b/report.md
index ba9d466..3c6d6f7 100644
--- a/report.md
+++ b/report.md
@@ -1,10 +1,10 @@
-# Background Agents + Layout Restore Review
+# Sidebar Layout Persistence Review
## Verdict
SHIP
## Block-level findings
-None. The implementation adheres strictly to the `plan-bg-restore.md` specification across all tiers (core, API, and frontend).
+None. The implementation is robust and follows the established patterns for localStorage usage in this codebase.
## Ship-with-followup findings
None.
@@ -13,19 +13,26 @@ None.
None.
## What was checked
-- **A. getAllStatuses correctness**: PASS. The method in `packages/api/src/agent-manager.ts:714-751` returns `Record<string, TabStatusSnapshot>`. It conditionally includes `currentChunks` and `currentAssistantId` only for `running` tabs and performs a defensive shallow copy `[...tabAgent.currentChunks]`.
-- **B. Frontend TabStatusSnapshot mirror**: PASS. `packages/frontend/src/lib/types.ts:96-100` mirrors the core interface exactly.
-- **C. hydrateFromBackend**: PASS. `packages/frontend/src/lib/tabs.svelte.ts:432-562` implements the full hydration flow (GET /tabs -> GET /status -> parallel GET /tabs/:id/messages). It handles failure modes without throwing, implements the required idempotency check (`tabs.length > 0`), and correctly seeds in-flight messages.
-- **D. WS statuses handler**: PASS. `packages/frontend/src/lib/tabs.svelte.ts:581-644` reconciles status, seeds in-flight chunks for running tabs, and clears pointers for idle tabs. The desync recovery path (`reloadTabMessagesFromApi`) is preserved.
-- **E. App.svelte onMount**: PASS. `packages/frontend/src/App.svelte:78-105` sequences `hydrateFromBackend` before the fallback `createNewTab` and only creates a fresh tab if hydration yields nothing. WS connection lifecycle is preserved.
-- **F. Behavior preservation**: PASS. No new `beforeunload` or `unload` handlers were added. WS `onClose` in `packages/api/src/index.ts:60-66` correctly only unsubscribes. Explicit tab close in `tabs.svelte.ts` still calls `DELETE /tabs/:id`, which cancels and archives as before.
-- **G. Test coverage**: PASS.
- - API tests in `agent-manager.test.ts` cover empty state, idle snapshot, running snapshot, and defensive copy.
- - Frontend tests in `chat-store.test.ts` cover successful restore, in-flight seeding, failure tolerance, and idempotency.
-- **H. Race conditions**: PASS. `hydrateFromBackend` idempotency and the per-tab reconcile logic in the WS handler mitigate potential races between HTTP and WS data.
-- **I. Wire-shape symmetry**: PASS. The frontend mirror matches the core definition.
-- **J. Type-only vs runtime imports**: PASS. `agent-manager.ts:40` uses `type TabStatusSnapshot`.
+- **A. sidebar-storage.ts correctness**:
+ - `loadSidebarPanels` correctly handles all failure modes (missing key, malformed JSON, non-array types, and mixed-type arrays) without throwing. It returns a defensive shallow copy of the default layout.
+ - `saveSidebarPanels` is best-effort and safely swallows any storage errors (e.g., QuotaExceededError or SecurityError).
+ - The localStorage key `dispatch-sidebar-panels` is correctly namespaced.
+- **B. SidebarPanel.svelte integration**:
+ - Initialization correctly seeds the `panels` state using `loadSidebarPanels()`.
+ - The `$effect` correctly captures all changes to the `panels` array (addition, removal, and selection changes) due to the reactive read in the map function.
+ - Session-ephemeral `id` fields are correctly regenerated and not persisted, avoiding potential collisions across sessions.
+ - The 'minimum 1 panel' invariant is preserved by the existing UI logic (`{#if idx > 0}`) and reinforced by the storage fallback.
+- **C. Test coverage**:
+ - The unit tests in `sidebar-storage.test.ts` are comprehensive, covering initial load, valid round-trips, corruption, malformed data, filtering, and storage exceptions.
+ - Verified mutation isolation (fresh array on each load).
+- **D. Regression risks**:
+ - Add/remove and dropdown handlers are preserved and correctly trigger persistence via state reassignment.
+ - No infinite loops or race conditions identified; the `$effect` is one-way (state -> localStorage).
+- **E. Stylistic / consistency**:
+ - Matches the `dispatch-` prefix pattern seen in `config.ts`.
+ - Documentation and comments are thorough and clear.
## What was NOT checked
-- Performance with extremely high tab counts (>100) was not verified empirically, though the implementation uses `Promise.all` for message fetching to minimize latency.
-- Direct database state verification (the review was limited to code analysis and existing test coverage).
+- Persistence of the `sidebarOpen` toggle (explicitly out of scope).
+- Drag-to-reorder support (not implemented in the UI).
+- Backend synchronization (feature designed for per-device localStorage).