diff options
| author | Adam Malczewski <[email protected]> | 2026-06-22 14:55:45 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-22 14:55:45 +0900 |
| commit | b7ea4b7325c02bf29046ab232411c053b36a99bd (patch) | |
| tree | e0fc862f03a20fe070e28831d59a3450e7963214 | |
| parent | 0ab13155b0d32a6062797b3f3da1c093b30cc9f0 (diff) | |
| download | dispatch-web-b7ea4b7325c02bf29046ab232411c053b36a99bd.tar.gz dispatch-web-b7ea4b7325c02bf29046ab232411c053b36a99bd.zip | |
feat: persist sidebar layout + open/closed state between refreshes
Sidebar panel layout (which views are open and their order) and the
sidebar open/closed toggle are now persisted to localStorage. Default
layout is just the Model view at the top.
- ViewSidebar accepts an onChange callback that reports panel kinds
- App.svelte creates two createLocalStore instances (dispatch.sidebar.views
+ dispatch.sidebar.open) using the store's storage adapter
- AppStore exposes its storage instance so the shell persists via the
same adapter (test-injectable, not globalThis.localStorage)
- Tests pre-populate fake storage with ["extensions"] for the 4 tests
that need the Extensions view visible
686 tests green. 0 svelte-check warnings (2 pre-existing errors from
missing transport-contract exports, unchanged).
| -rw-r--r-- | src/app/App.svelte | 39 | ||||
| -rw-r--r-- | src/app/App.test.ts | 16 | ||||
| -rw-r--r-- | src/app/store.svelte.ts | 6 | ||||
| -rw-r--r-- | src/features/views/ui/ViewSidebar.svelte | 10 |
4 files changed, 51 insertions, 20 deletions
diff --git a/src/app/App.svelte b/src/app/App.svelte index 41e68ef..57fe16f 100644 --- a/src/app/App.svelte +++ b/src/app/App.svelte @@ -45,6 +45,8 @@ manifest as workspaceManifest, } from "../features/workspace"; import type { AppStore } from "./store.svelte"; + import { createLocalStore } from "../adapters/local-storage"; + import { untrack } from "svelte"; let { store }: { store: AppStore } = $props(); @@ -72,16 +74,16 @@ { id: "settings", label: "Settings" }, ] as const; - // Default sidebar layout: Model, Language Servers, Extensions, Cache Warming, Tasks, Compaction, Settings. - const initialViews = [ - "model", - "lsp", - "extensions", - "cache-warming", - "tasks", - "compaction", - "settings", - ] as const; + // Default sidebar layout: just the Model view. + const DEFAULT_VIEWS: readonly string[] = ["model"]; + const sidebarStore = createLocalStore<readonly string[]>("dispatch.sidebar.views", { + storage: untrack(() => store.storage), + }); + const sidebarPanels = sidebarStore.load() ?? DEFAULT_VIEWS; + + function handleSidebarChange(kinds: readonly (string | null)[]): void { + sidebarStore.save(kinds.filter((k): k is string => k !== null)); + } // Frontend module list for the "Loaded Modules" view, AGGREGATED from each // feature's public `manifest` export so it can't drift from what's actually @@ -177,11 +179,18 @@ smartScroll.reset(); }); - // Right sidebar: open by default on wide screens (pushes the chat aside), - // closed by default on narrow screens (overlays the chat). Initial state is - // derived from the viewport width once; the hamburger toggles it thereafter. + // Right sidebar: persisted open/closed state. Defaults to open on wide + // screens (first visit), then remembers the user's toggle thereafter. const WIDE_BREAKPOINT = 1024; // Tailwind `lg` - let sidebarOpen = $state(typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true); + const sidebarOpenStore = createLocalStore<boolean>("dispatch.sidebar.open", { + storage: untrack(() => store.storage), + }); + const storedSidebarOpen = sidebarOpenStore.load(); + let sidebarOpen = $state(storedSidebarOpen ?? (typeof window !== "undefined" ? window.innerWidth >= WIDE_BREAKPOINT : true)); + + $effect(() => { + sidebarOpenStore.save(sidebarOpen); + }); function handleInvoke(msg: InvokeMessage) { store.invoke(msg.surfaceId, msg.actionId, msg.payload); @@ -404,7 +413,7 @@ class="flex h-full w-80 flex-col gap-2 overflow-y-auto border-l border-base-300 bg-base-100 p-3 transition-transform duration-300 ease-out" style="transform: translateX({sidebarOpen ? '0' : '100%'})" > - <ViewSidebar kinds={viewKinds} initial={initialViews} content={viewContent} /> + <ViewSidebar kinds={viewKinds} initial={sidebarPanels} onChange={handleSidebarChange} content={viewContent} /> </div> </aside> diff --git a/src/app/App.test.ts b/src/app/App.test.ts index d22f84b..6a39296 100644 --- a/src/app/App.test.ts +++ b/src/app/App.test.ts @@ -98,6 +98,12 @@ function createFakeStorage(): Storage { }; } +function createFakeStorageWithViews(views: readonly string[] = ["extensions"]): Storage { + const storage = createFakeStorage(); + storage.setItem("dispatch.sidebar.views", JSON.stringify(views)); + return storage; +} + function sentMessages(ws: FakeSocket) { return ws.sent.map((s) => JSON.parse(s)); } @@ -161,7 +167,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), + localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -225,7 +231,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), + localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -345,7 +351,7 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), + localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); @@ -388,13 +394,13 @@ describe("App component interaction tests", () => { const store = createAppStore({ socketFactory: () => ws, fetchImpl: fakeFetchImpl(), - localStorage: createFakeStorage(), + localStorage: createFakeStorageWithViews(), }); ws.resolveOpen(); render(App, { props: { store } }); - // Extensions is the default view, so the modules table renders immediately. + // Extensions view is pre-populated in the fake storage, so the modules table renders immediately. expect(screen.getByRole("columnheader", { name: "Module" })).toBeInTheDocument(); for (const name of [ "chat", diff --git a/src/app/store.svelte.ts b/src/app/store.svelte.ts index f212920..bb08585 100644 --- a/src/app/store.svelte.ts +++ b/src/app/store.svelte.ts @@ -93,6 +93,9 @@ export interface AppStore { /** Every received surface spec, in catalog order — all auto-subscribed + expanded. */ readonly surfaces: readonly SurfaceSpec[]; readonly lastError: ProtocolState["lastError"]; + /** The localStorage instance the store uses for persistence (tabs, chatLimit). + * Exposed so the shell can persist sidebar layout via the same adapter. */ + readonly storage: Storage | undefined; /** The current spec for one surface by id (discovery-by-id), or null if absent. */ surface(surfaceId: string): SurfaceSpec | null; send(text: string): void; @@ -747,6 +750,9 @@ export function createAppStore(opts?: CreateAppStoreOptions): AppStore { get lastError() { return protocol.lastError; }, + get storage() { + return localStorageOpt; + }, get cwd(): string | null { return cwd; }, diff --git a/src/features/views/ui/ViewSidebar.svelte b/src/features/views/ui/ViewSidebar.svelte index c4b466f..e4a3ee6 100644 --- a/src/features/views/ui/ViewSidebar.svelte +++ b/src/features/views/ui/ViewSidebar.svelte @@ -17,6 +17,7 @@ kinds, content, initial, + onChange, }: { /** The view kinds offered in every panel's dropdown. */ kinds: readonly ViewKind[]; @@ -24,6 +25,8 @@ content: Snippet<[string]>; /** Optional seed of panel kinds; defaults to one panel of the first kind. */ initial?: readonly (string | null)[]; + /** Called whenever the panel layout changes (add/remove/select). */ + onChange?: (kinds: readonly (string | null)[]) => void; } = $props(); // Local UI composition state, owned by this unit and folded through the pure @@ -32,6 +35,10 @@ let state = $state<PanelsState>( untrack(() => initialPanels(initial ?? [kinds[0]?.id ?? null])), ); + + function notify(): void { + onChange?.(state.panels.map((p) => p.kind)); + } </script> <div class="flex min-h-0 flex-col gap-2"> @@ -45,6 +52,7 @@ onchange={(e) => { const v = e.currentTarget.value; state = selectKind(state, panel.id, v === "" ? null : v); + notify(); }} > <option value="" disabled>Select a view</option> @@ -59,6 +67,7 @@ aria-label="Remove view" onclick={() => { state = removePanel(state, panel.id); + notify(); }} > ✕ @@ -80,6 +89,7 @@ aria-label="Add view" onclick={() => { state = addPanel(state); + notify(); }} > + |
