summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-22 14:55:45 +0900
committerAdam Malczewski <[email protected]>2026-06-22 14:55:45 +0900
commitb7ea4b7325c02bf29046ab232411c053b36a99bd (patch)
treee0fc862f03a20fe070e28831d59a3450e7963214
parent0ab13155b0d32a6062797b3f3da1c093b30cc9f0 (diff)
downloaddispatch-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.svelte39
-rw-r--r--src/app/App.test.ts16
-rw-r--r--src/app/store.svelte.ts6
-rw-r--r--src/features/views/ui/ViewSidebar.svelte10
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();
}}
>
+