diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 08:41:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 08:41:33 +0900 |
| commit | 6662622e1c89fa1124b7342f136ab5f8d3c97972 (patch) | |
| tree | 71a35186476e14af0ccaa1c11be24953e518a08e | |
| parent | d2e2e67425e5106025ee8082a0768989b5de814f (diff) | |
| download | dispatch-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.svelte | 15 | ||||
| -rw-r--r-- | packages/frontend/src/lib/sidebar-storage.ts | 84 | ||||
| -rw-r--r-- | packages/frontend/tests/sidebar-storage.test.ts | 148 | ||||
| -rw-r--r-- | report.md | 39 |
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"]); + }); +}); @@ -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). |
