summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout')
-rw-r--r--src/features/layout/adapter/http.test.ts136
-rw-r--r--src/features/layout/adapter/http.ts117
-rw-r--r--src/features/layout/index.ts3
-rw-r--r--src/features/layout/logic/migration.test.ts72
-rw-r--r--src/features/layout/logic/migration.ts76
-rw-r--r--src/features/layout/store.svelte.ts270
-rw-r--r--src/features/layout/store.test.ts420
7 files changed, 1094 insertions, 0 deletions
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
new file mode 100644
index 0000000..5b50715
--- /dev/null
+++ b/src/features/layout/adapter/http.test.ts
@@ -0,0 +1,136 @@
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { createLayoutHttp } from "./http";
+
+/**
+ * Layout HTTP adapter tests — the OUTERMOST edge (fetch) is the only fake:
+ * a scripted `fetchImpl` returning canned Responses. No internal mocks.
+ */
+
+const LAYOUT_URL = "http://backend.test/workspaces/ws-a/layout";
+
+function fetchReturning(
+ status: number,
+ body: unknown,
+ capture?: { url?: string; init?: RequestInit | undefined },
+): typeof fetch {
+ return async (input: string | URL | Request, init?: RequestInit): Promise<Response> => {
+ if (capture !== undefined) {
+ capture.url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ capture.init = init;
+ }
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+ };
+}
+
+describe("createLayoutHttp — getTree", () => {
+ it("GETs the well-known route and unwraps { tree }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.url).toBe(LAYOUT_URL);
+ expect(capture.init?.method).toBeUndefined(); // GET is the default
+ });
+
+ it("maps a 404 to the no-provider signal (notFound: true)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(404, { error: "No layout provider is active" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ });
+
+ it("maps a 500 to a non-notFound failure carrying the body's error", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(500, { error: "Layout storage failure" }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toEqual({ ok: false, notFound: false, error: "Layout storage failure" });
+ });
+
+ it("a network failure degrades to notFound (the no-provider mode)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("Failed to fetch");
+ });
+ const result = await http.getTree("ws-a");
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.notFound).toBe(true);
+ expect(result.error).toContain("Failed to fetch");
+ }
+ });
+
+ it("a malformed 200 (SPA-fallback JSON without a tree) degrades to notFound", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { chunks: [], latestSeq: 0 }),
+ );
+ const result = await http.getTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — putTree", () => {
+ it("PUTs { tree } as JSON and unwraps the echo", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("PUT");
+ expect(capture.init?.body).toBe(JSON.stringify({ tree: DEFAULT_LAYOUT }));
+ });
+
+ it("maps a 400 { error } to a validation failure (not notFound)", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "root: split has no children" }),
+ );
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toEqual({
+ ok: false,
+ notFound: false,
+ error: "root: split has no children",
+ });
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.putTree("ws-a", DEFAULT_LAYOUT);
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
+
+describe("createLayoutHttp — deleteTree", () => {
+ it("DELETEs and unwraps the fresh seed", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT }, capture),
+ );
+ const result = await http.deleteTree("ws-a");
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT });
+ expect(capture.init?.method).toBe("DELETE");
+ });
+
+ it("maps a 404 to the no-provider signal", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.deleteTree("ws-a");
+ expect(result).toMatchObject({ ok: false, notFound: true });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
new file mode 100644
index 0000000..2f91e43
--- /dev/null
+++ b/src/features/layout/adapter/http.ts
@@ -0,0 +1,117 @@
+import type { LayoutTree } from "@dispatch/layout-contract";
+
+/**
+ * Layout HTTP effects (plan 02 §2.6) — the injected edge that talks to the
+ * ACTIVE layout provider's well-known routes:
+ * - `GET /workspaces/:id/layout` → `{ tree }` (read seeds a fresh workspace)
+ * - `PUT /workspaces/:id/layout` body `{ tree }` → 200 `{ tree }` / 400 `{ error }`
+ * - `DELETE /workspaces/:id/layout` → the fresh seed (reset; plan 04 adds the UI)
+ *
+ * Mirrors the plugins adapter's pattern: `httpBase` + an injected `fetchImpl`
+ * (testable without the network). The 404 is a SIGNAL, not an error: it means
+ * no layout provider is active, and the store renders its local default with
+ * ephemeral edits (never an error UI). A network failure degrades the same way.
+ */
+
+/** Outcome of a layout read/write. `notFound` distinguishes the no-provider 404. */
+export type LayoutHttpResult =
+ | { readonly ok: true; readonly tree: LayoutTree }
+ | { readonly ok: false; readonly notFound: boolean; readonly error: string };
+
+export interface LayoutHttp {
+ getTree(workspaceId: string): Promise<LayoutHttpResult>;
+ putTree(workspaceId: string, tree: LayoutTree): Promise<LayoutHttpResult>;
+ deleteTree(workspaceId: string): Promise<LayoutHttpResult>;
+}
+
+/** Read an error message from a non-2xx body (`{ error }`), tolerating junk. */
+async function errorOf(res: Response): Promise<string> {
+ try {
+ const body = (await res.json()) as { error?: unknown };
+ if (typeof body.error === "string" && body.error.length > 0) return body.error;
+ } catch {
+ // fall through
+ }
+ return `HTTP ${res.status}`;
+}
+
+/** A malformed 200 (e.g. an SPA-fallback page) degrades like a 404 — never poisons the store. */
+function malformed(): LayoutHttpResult {
+ return { ok: false, notFound: true, error: "Malformed layout response" };
+}
+
+export function createLayoutHttp(httpBase: string, fetchImpl: typeof fetch): LayoutHttp {
+ return {
+ async getTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async putTree(workspaceId, tree): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ tree }),
+ },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+
+ async deleteTree(workspaceId): Promise<LayoutHttpResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout`,
+ { method: "DELETE" },
+ );
+ if (res.status === 404) {
+ return { ok: false, notFound: true, error: "No layout provider is active" };
+ }
+ if (!res.ok) return { ok: false, notFound: false, error: await errorOf(res) };
+ const body = (await res.json()) as { tree?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return malformed();
+ return { ok: true, tree: body.tree as LayoutTree };
+ } catch (err) {
+ return {
+ ok: false,
+ notFound: true,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+ };
+}
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
index a2e93a6..f5696ac 100644
--- a/src/features/layout/index.ts
+++ b/src/features/layout/index.ts
@@ -7,6 +7,7 @@
* OUTSIDE the tree — the tree owns the content region.
*/
+export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http";
export {
bootDefaultOpen,
type CollapseState,
@@ -17,6 +18,7 @@ export {
SIDEBAR_OPEN_KEY,
WIDE_BREAKPOINT,
} from "./logic/collapse";
+export { migrationTree, sidePanelViews, storedPanelViews } from "./logic/migration";
export { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./logic/panels-edit";
export {
getView,
@@ -31,6 +33,7 @@ export {
} from "./logic/registry";
export { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./logic/seed";
export { firstCollapsibleNodeId, firstPanelsNode } from "./logic/tree-query";
+export { createLayoutStore, type LayoutProviderState, type LayoutStore } from "./store.svelte";
export { default as TileRoot } from "./ui/TileRoot.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts
new file mode 100644
index 0000000..0b2b402
--- /dev/null
+++ b/src/features/layout/logic/migration.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import {
+ migrationTree,
+ sidePanelViews,
+ storedPanelViews,
+} from "./migration";
+import { overlaySidePanels } from "./seed";
+
+const KNOWN = new Set(["tabs", "model", "extensions", "vision", "tasks"]);
+
+describe("storedPanelViews", () => {
+ it("parses a stored array of view ids, filtered to known views, order kept", () => {
+ expect(storedPanelViews(["vision", "junk", "tasks"], KNOWN)).toEqual(["vision", "tasks"]);
+ });
+
+ it("returns null for absent/junk/non-array values", () => {
+ expect(storedPanelViews(null, KNOWN)).toBeNull();
+ expect(storedPanelViews(undefined, KNOWN)).toBeNull();
+ expect(storedPanelViews("extensions", KNOWN)).toBeNull();
+ expect(storedPanelViews({ 0: "tabs" }, KNOWN)).toBeNull();
+ });
+
+ it("returns null when nothing stored is a known view", () => {
+ expect(storedPanelViews(["deleted-view", "another-gone"], KNOWN)).toBeNull();
+ expect(storedPanelViews([], KNOWN)).toBeNull();
+ });
+});
+
+describe("sidePanelViews", () => {
+ it("reads the default tree's side panels", () => {
+ expect(sidePanelViews(DEFAULT_LAYOUT)).toEqual(["tabs", "model"]);
+ });
+
+ it("returns null for a tree without a 'side' panels node", () => {
+ expect(sidePanelViews({ version: 1, root: { kind: "leaf", view: "chat" } })).toBeNull();
+ });
+});
+
+describe("migrationTree (§2.7 — one-way, best-effort)", () => {
+ it("imports the stored selection when the fetched tree is the untouched default seed", () => {
+ const stored = JSON.stringify(["extensions", "tasks"]);
+ const migrated = migrationTree(JSON.parse(stored), KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"]));
+ // The rest of the tree is untouched — only the side panels changed.
+ expect(migrated?.root).toEqual(
+ overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "tasks"])?.root,
+ );
+ });
+
+ it("returns null when the key is absent / junk / has no known views (nothing to import)", () => {
+ expect(migrationTree(null, KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree("junk", KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ expect(migrationTree(["gone-view"], KNOWN, DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("returns null when the provider's tree is NOT the default seed (custom layout wins)", () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ expect(migrationTree(["extensions"], KNOWN, custom)).toBeNull();
+ });
+
+ it("returns null when the fetched tree has no side panels node at all", () => {
+ expect(
+ migrationTree(["extensions"], KNOWN, { version: 1, root: { kind: "leaf", view: "chat" } }),
+ ).toBeNull();
+ });
+
+ it("a stored selection identical to the default still migrates (a harmless idempotent PUT)", () => {
+ const migrated = migrationTree(["tabs", "model"], KNOWN, DEFAULT_LAYOUT);
+ expect(migrated).toEqual(DEFAULT_LAYOUT);
+ });
+});
diff --git a/src/features/layout/logic/migration.ts b/src/features/layout/logic/migration.ts
new file mode 100644
index 0000000..e0ed6fd
--- /dev/null
+++ b/src/features/layout/logic/migration.ts
@@ -0,0 +1,76 @@
+/**
+ * localStorage → provider migration (plan 02 §2.7, D-L4; one-way, best-effort).
+ *
+ * `dispatch.sidebar.views` is retired as a source of truth: on boot WITH a
+ * provider present, if the key exists AND the fetched tree's `side` panels
+ * equal DEFAULT_LAYOUT's (the workspace still has the untouched default
+ * seed), the stored panel selection is imported once via a PUT. When the
+ * provider's tree differs from the default (e.g. a calendar provider), the
+ * workspace already has a custom layout — importing the old sidebar override
+ * would be wrong. Regardless of outcome the key is never read again (the
+ * bytes stay, but nothing consumes them).
+ *
+ * Pure decision logic: the store owns the effects (the localStorage read +
+ * the PUT); this module decides IF and WHAT.
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutTree,
+ type PanelsNode,
+} from "@dispatch/layout-contract";
+import { overlaySidePanels } from "./seed";
+
+/**
+ * The stored selection's usable view ids: an array of strings filtered to the
+ * known (registered) views, in stored order. null when nothing usable is
+ * stored (absent key, junk, or no known ids) — nothing to import.
+ */
+export function storedPanelViews(
+ raw: unknown,
+ knownViews: ReadonlySet<string>,
+): readonly string[] | null {
+ if (!Array.isArray(raw)) return null;
+ const known = raw.filter((v): v is string => typeof v === "string" && knownViews.has(v));
+ return known.length > 0 ? known : null;
+}
+
+/**
+ * The view ids of a tree's `"side"` panels node (the default tree's swappable
+ * sidebar stack), or null when the tree has no such node.
+ */
+export function sidePanelViews(tree: LayoutTree): readonly string[] | null {
+ const found = findById(tree, "side");
+ if (found === null || found.node.kind !== "panels") return null;
+ return (found.node as PanelsNode).panels.map((p) => p.view);
+}
+
+function samePanels(a: readonly string[], b: readonly string[]): boolean {
+ return a.length === b.length && a.every((v, i) => v === b[i]);
+}
+
+/**
+ * The one-way migration decision (§2.7): given the RAW stored value, the
+ * known view ids, and the tree fetched from the provider, return the tree to
+ * PUT once — or null when there is nothing to migrate:
+ * - the stored key holds no usable selection, or
+ * - the provider's tree is NOT the untouched default seed (its side panels
+ * differ from DEFAULT_LAYOUT's — the workspace already has a custom layout).
+ */
+export function migrationTree(
+ storedRaw: unknown,
+ knownViews: ReadonlySet<string>,
+ fetched: LayoutTree,
+): LayoutTree | null {
+ const stored = storedPanelViews(storedRaw, knownViews);
+ if (stored === null) return null;
+ const fetchedPanels = sidePanelViews(fetched);
+ const defaultPanels = sidePanelViews(DEFAULT_LAYOUT);
+ if (fetchedPanels === null || defaultPanels === null) return null;
+ if (!samePanels(fetchedPanels, defaultPanels)) return null;
+ // Overlay the stored selection onto the fetched tree (identical to the
+ // default here by the guard above — the seed overlay keeps the rest of the
+ // tree shared, not copied).
+ return overlaySidePanels(fetched, stored);
+}
diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts
new file mode 100644
index 0000000..3d7de3f
--- /dev/null
+++ b/src/features/layout/store.svelte.ts
@@ -0,0 +1,270 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { migrationTree } from "./logic/migration";
+
+/**
+ * Layout store (plan 02 §2.6) — a thin reactive wrapper over the injected
+ * `LayoutHttp` edge (the plugins-store pattern). Owns the ACTIVE workspace's
+ * tree and the provider lifecycle:
+ *
+ * - **Provider probe triggers** — boot, every workspace switch, and every
+ * `extensions-changed` (a provider enabled/disabled mid-session starts/
+ * stops serving; the probe picks it up without a reload).
+ * - **No-provider mode** — a 404 (or network failure) keeps the tree on its
+ * local default; edits still work but are EPHEMERAL (in-memory only —
+ * D-L4 retired the localStorage write path); logged once, no error UI.
+ * - **WS push** — `onServerTree(workspaceId, tree)` applies a `layout`
+ * broadcast, ignoring non-active workspaces (the server fans out to all
+ * clients; the client filters).
+ * - **User edits persist through the provider** — `onTreeChange` applies the
+ * edit locally immediately, then PUTs the WHOLE tree (decision 7 — no
+ * patch protocol) lightly debounced; on a failed PUT the tree rolls back
+ * to the last server-known tree with a quiet console warning.
+ * - **Collapse state stays local** (decision 3) — `dispatch.sidebar.open`
+ * is the collapse store's concern, untouched here.
+ * - **One-way localStorage migration** (§2.7) — on the FIRST successful
+ * provider read of the boot session, if `dispatch.sidebar.views` exists
+ * AND the fetched tree is the untouched default seed, the stored panel
+ * selection is imported with one PUT; the key is then never read again.
+ *
+ * No ambient state: one instance per composition root, passed explicitly;
+ * no module-level mutables.
+ */
+
+/** Whether an active provider is serving the active workspace. */
+export type LayoutProviderState = "unknown" | "present" | "absent";
+
+export interface LayoutStore {
+ /** The rendered tree for the ACTIVE workspace (starts at the local default). */
+ readonly tree: LayoutTree;
+ /** The active provider's state (absent = the no-provider degrade path). */
+ readonly providerState: LayoutProviderState;
+ /** The workspace the tree currently belongs to. */
+ readonly activeWorkspaceId: string;
+ /**
+ * Probe the provider for `workspaceId`'s tree. `boot: true` (the composition
+ * root's boot call) also arms the one-way localStorage migration (§2.7),
+ * which consumes itself on the first successful provider read regardless of
+ * outcome. A probe is the missed-push catch-up (§2.5).
+ */
+ probe(workspaceId: string, opts?: { readonly boot?: boolean }): Promise<void>;
+ /** Workspace switch: reset to the local default for the new workspace + probe. */
+ onWorkspaceChanged(workspaceId: string): void;
+ /** Server `layout` broadcast (§2.5). Non-active workspaces are ignored. */
+ onServerTree(workspaceId: string, tree: LayoutTree): void;
+ /** A user edit (the panels' dropdowns / TileRoot ops). Persists via PUT. */
+ onTreeChange(next: LayoutTree): void;
+ /** Multi-client coherence (§11): re-probe on `extensions-changed`. */
+ onExtensionsChanged(): void;
+}
+
+export interface LayoutStoreOptions {
+ readonly http: LayoutHttp;
+ /** The boot workspace (its URL slug); "default" if absent. */
+ readonly workspaceId?: string;
+ /**
+ * Injected localStorage — read ONLY by the one-way §2.7 migration (the
+ * write path is retired; collapse state has its own store). Undefined →
+ * migration never applies (tests / storageless contexts).
+ */
+ readonly storage?: Storage | undefined;
+ /** The known (registered) view ids, for the migration's unknown-id filter. */
+ readonly knownViews?: ReadonlySet<string> | readonly string[];
+ /**
+ * The localStorage key the §2.7 migration reads (default
+ * `dispatch.sidebar.views`; tests override).
+ */
+ readonly sidebarViewsKey?: string;
+ /** Edit-PUT debounce window in ms (default 300; tests shrink it). */
+ readonly debounceMs?: number;
+ /** The quiet-failure hook (default `console.warn` — never an error UI). */
+ readonly warn?: (message: string) => void;
+}
+
+const DEFAULT_SIDEBAR_VIEWS_KEY = "dispatch.sidebar.views";
+const DEFAULT_DEBOUNCE_MS = 300;
+
+function toKnownSet(known: LayoutStoreOptions["knownViews"]): ReadonlySet<string> {
+ if (known === undefined) return new Set();
+ return known instanceof Set ? known : new Set(known);
+}
+
+export function createLayoutStore(opts: LayoutStoreOptions): LayoutStore {
+ const http = opts.http;
+ const storage = opts.storage;
+ const sidebarViewsKey = opts.sidebarViewsKey ?? DEFAULT_SIDEBAR_VIEWS_KEY;
+ const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const knownViews = toKnownSet(opts.knownViews);
+ const warn = opts.warn ?? ((message: string) => console.warn(message));
+
+ let tree = $state<LayoutTree>(DEFAULT_LAYOUT);
+ let providerState = $state<LayoutProviderState>("unknown");
+ let activeWorkspaceId = $state<string>(opts.workspaceId ?? "default");
+
+ /** The last tree known to come from the server (GET / WS push / PUT echo) — the rollback target. */
+ let lastServerTree: LayoutTree | null = null;
+ /** The pending edit-PUT timer (the "lightly debounced" write). */
+ let putTimer: ReturnType<typeof setTimeout> | null = null;
+ /** Logged-once guard for the no-provider log (reset when a provider appears). */
+ let loggedAbsent = false;
+ /**
+ * The §2.7 migration is armed by the BOOT probe and consumed by the first
+ * successful provider read — regardless of outcome (read-once semantics).
+ */
+ let migrationArmed = false;
+
+ function noteAbsent(reason: string): void {
+ providerState = "absent";
+ if (!loggedAbsent) {
+ loggedAbsent = true;
+ warn(
+ `layout: no layout provider active (${reason}) — rendering the built-in default; edits are ephemeral`,
+ );
+ }
+ }
+
+ function notePresent(): void {
+ providerState = "present";
+ loggedAbsent = false;
+ }
+
+ function applyServerTree(next: LayoutTree): void {
+ lastServerTree = next;
+ tree = next;
+ }
+
+ /** The §2.7 one-way migration: at most one read + one PUT per session. */
+ async function maybeMigrate(): Promise<void> {
+ if (!migrationArmed || storage === undefined) return;
+ migrationArmed = false; // consumed regardless of outcome (stop reading the key)
+ let raw: string | null = null;
+ try {
+ raw = storage.getItem(sidebarViewsKey);
+ } catch {
+ return; // storage unavailable — best-effort, done
+ }
+ if (raw === null) return; // nothing stored — nothing to import
+ let parsed: unknown = null;
+ try {
+ parsed = JSON.parse(raw) as unknown;
+ } catch {
+ return; // junk — best-effort, done
+ }
+ const migrated = migrationTree(parsed, knownViews, tree);
+ if (migrated === null) return; // custom layout already / nothing usable
+ const result = await http.putTree(activeWorkspaceId, migrated);
+ if (result.ok) {
+ // The server's canonical copy (also echoed back via the WS broadcast).
+ applyServerTree(result.tree);
+ } else if (!result.notFound) {
+ warn(`layout: localStorage migration PUT failed — ${result.error}`);
+ }
+ }
+
+ async function runProbe(workspaceId: string, boot: boolean): Promise<void> {
+ if (boot) migrationArmed = true;
+ const result: LayoutHttpResult = await http.getTree(workspaceId);
+ // A switch may have happened while the fetch was in flight — only the
+ // CURRENT workspace's result lands (the refreshProviderStatus guard).
+ if (workspaceId !== activeWorkspaceId) return;
+ if (result.ok) {
+ notePresent();
+ applyServerTree(result.tree);
+ await maybeMigrate();
+ } else if (result.notFound) {
+ noteAbsent(result.error);
+ } else {
+ // A real failure (5xx, malformed) degrades like absent — quietly.
+ noteAbsent(result.error);
+ }
+ }
+
+ function clearPutTimer(): void {
+ if (putTimer !== null) {
+ clearTimeout(putTimer);
+ putTimer = null;
+ }
+ }
+
+ async function flushPut(workspaceId: string, pending: LayoutTree): Promise<void> {
+ putTimer = null;
+ // Read the CURRENT tree at fire time: a newer edit supersedes the
+ // debounced snapshot (one in-flight write per window, last wins).
+ const target = tree === pending ? pending : tree;
+ const result = await http.putTree(workspaceId, target);
+ if (result.ok) {
+ notePresent();
+ // The server's validated echo is the new rollback target.
+ lastServerTree = result.tree;
+ return;
+ }
+ if (result.notFound) {
+ // The provider vanished: keep the local edit (ephemeral), no rollback —
+ // there is no server tree to roll back TO.
+ noteAbsent(result.error);
+ return;
+ }
+ // A failed write on a PRESENT provider: roll back to the last server
+ // tree, quiet console warning (never an error UI).
+ warn(
+ `layout: persisting the tree failed (${result.error}) — rolled back to the last server tree`,
+ );
+ if (lastServerTree !== null) tree = lastServerTree;
+ }
+
+ return {
+ get tree() {
+ return tree;
+ },
+ get providerState() {
+ return providerState;
+ },
+ get activeWorkspaceId() {
+ return activeWorkspaceId;
+ },
+
+ async probe(workspaceId, probeOpts) {
+ await runProbe(workspaceId, probeOpts?.boot === true);
+ },
+
+ onWorkspaceChanged(workspaceId) {
+ if (workspaceId === activeWorkspaceId) return;
+ activeWorkspaceId = workspaceId;
+ // The tree belongs to the OLD workspace now: reset to the local default
+ // while the probe resolves (never flash the previous workspace's tree),
+ // and drop any queued edit — it was for the old workspace.
+ clearPutTimer();
+ lastServerTree = null;
+ tree = DEFAULT_LAYOUT;
+ providerState = "unknown";
+ void runProbe(workspaceId, false);
+ },
+
+ onServerTree(workspaceId, next) {
+ if (workspaceId !== activeWorkspaceId) return; // clients filter (§2.5)
+ notePresent();
+ clearPutTimer(); // the server's copy supersedes any queued edit
+ applyServerTree(next);
+ },
+
+ onTreeChange(next) {
+ tree = next; // the edit applies locally IMMEDIATELY (the UI never waits)
+ if (providerState === "absent") return; // ephemeral — no write, no warning
+ clearPutTimer();
+ putTimer = setTimeout(() => {
+ void flushPut(activeWorkspaceId, next).catch(() => {
+ // Network-grade failure: roll back to the last server tree, quietly.
+ warn("layout: persisting the tree failed — rolled back to the last server tree");
+ if (lastServerTree !== null) tree = lastServerTree;
+ });
+ }, debounceMs);
+ },
+
+ onExtensionsChanged() {
+ void runProbe(activeWorkspaceId, false).catch(() => {
+ // runProbe never throws for HTTP failures; this guard is for the
+ // migration path. Staying silent keeps the degrade quiet.
+ });
+ },
+ };
+}
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
new file mode 100644
index 0000000..7e7d6bc
--- /dev/null
+++ b/src/features/layout/store.test.ts
@@ -0,0 +1,420 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { overlaySidePanels } from "./logic/seed";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { createLayoutStore } from "./store.svelte";
+
+/**
+ * Layout store tests (plan 02 verification gate) — mock HTTP EDGES ONLY (the
+ * injected `LayoutHttp` is the store's outermost boundary; nothing internal
+ * is mocked — asymmetric testing per AGENTS.md). Fake timers drive the edit
+ * PUT debounce deterministically.
+ */
+
+/** A recording fake of the LayoutHttp edge, scripted per test. */
+interface HttpFake extends LayoutHttp {
+ getCalls: string[];
+ putCalls: Array<{ workspaceId: string; tree: LayoutTree }>;
+ getResult: (workspaceId: string) => LayoutHttpResult;
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult;
+}
+
+function fakeHttp(
+ getResult: (workspaceId: string) => LayoutHttpResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ }),
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult = (_ws, tree) => ({
+ ok: true,
+ tree,
+ }),
+): HttpFake {
+ const getCalls: string[] = [];
+ const putCalls: Array<{ workspaceId: string; tree: LayoutTree }> = [];
+ const fake: HttpFake = {
+ getCalls,
+ putCalls,
+ getResult,
+ putResult,
+ // Delegate to the CURRENT properties so a test can re-script outcomes
+ // mid-test (http.putResult = …).
+ async getTree(workspaceId) {
+ getCalls.push(workspaceId);
+ return fake.getResult(workspaceId);
+ },
+ async putTree(workspaceId, tree) {
+ putCalls.push({ workspaceId, tree });
+ return fake.putResult(workspaceId, tree);
+ },
+ async deleteTree() {
+ return { ok: false, notFound: true, error: "unused" };
+ },
+ };
+ return fake;
+}
+
+function storageWith(views: string[] | null): Storage {
+ const map = new Map<string, string>();
+ if (views !== null) map.set("test.sidebar.views", JSON.stringify(views));
+ return {
+ get length() {
+ return map.size;
+ },
+ clear() {
+ map.clear();
+ },
+ getItem: (k) => map.get(k) ?? null,
+ key: () => null,
+ removeItem: (k) => map.delete(k),
+ setItem: (k, v) => map.set(k, v),
+ };
+}
+
+const KNOWN = ["tabs", "model", "extensions", "vision"];
+
+function makeStore(http: HttpFake, extra: Record<string, unknown> = {}) {
+ return createLayoutStore({
+ http,
+ workspaceId: "ws-a",
+ knownViews: KNOWN,
+ sidebarViewsKey: "test.sidebar.views",
+ debounceMs: 100,
+ warn: (message) => warnings.push(message),
+ ...extra,
+ });
+}
+
+let warnings: string[] = [];
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ warnings = [];
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("layout store — no-provider fallback (§2.6)", () => {
+ it("a 404 probe keeps the default tree, marks the provider absent, and logs ONCE", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+
+ await store.probe("ws-a", { boot: true });
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(warnings).toHaveLength(1);
+ expect(warnings[0]).toContain("no layout provider");
+
+ // A re-probe (extensions-changed while still absent) does not re-warn.
+ await store.probe("ws-a");
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("a network failure degrades like a 404 (quiet, no error UI)", async () => {
+ const http = fakeHttp(() => ({ ok: false, notFound: true, error: "fetch blew up" }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ });
+
+ it("edits still apply in no-provider mode but are EPHEMERAL (no PUT, no rollback, no warning)", async () => {
+ const http = fakeHttp();
+ const store = makeStore(http);
+ await store.probe("ws-a"); // absent now
+ expect(store.providerState).toBe("absent");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ expect(store.tree).toEqual(edited);
+
+ vi.advanceTimersByTime(500);
+ expect(http.putCalls).toEqual([]); // ephemeral: no write attempted
+ // Quiet = the single absence log (from the probe), nothing more.
+ expect(warnings).toHaveLength(1);
+ });
+
+ it("an edit while the probe is still in flight is PUT optimistically; a 404 flips absent and KEEPS the edit", async () => {
+ const http = fakeHttp();
+ // The PUT is a 404 (the probe will confirm: no provider).
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const store = makeStore(http);
+
+ void store.probe("ws-a"); // in flight (microtask pending)
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited); // lands BEFORE the probe resolves
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1); // the optimistic write WAS attempted
+ expect(store.providerState).toBe("absent");
+ expect(store.tree).toEqual(edited); // no rollback — there is no server tree
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — provider present", () => {
+ it("a 200 probe replaces the tree and marks the provider present", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ expect(warnings).toEqual([]);
+ });
+});
+
+describe("layout store — WS push (§2.5)", () => {
+ it("a layout push for the ACTIVE workspace replaces the tree", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const pushed = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-a", pushed);
+ expect(store.tree).toEqual(pushed);
+ });
+
+ it("a push for a NON-active workspace is ignored", async () => {
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const other = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ store.onServerTree("ws-b", other);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("after a workspace switch the OLD workspace's push is ignored (clients filter)", async () => {
+ const servedA = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ let served = servedA;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const servedB = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ served = servedB;
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(0); // the probe's promise
+ expect(store.tree).toEqual(servedB);
+
+ // A late push for the OLD workspace must not land.
+ const stale = overlaySidePanels(DEFAULT_LAYOUT, ["tasks"]);
+ store.onServerTree("ws-a", stale);
+ expect(store.tree).toEqual(servedB);
+ });
+});
+
+describe("layout store — edits PUT the whole tree + rollback (§2.6)", () => {
+ it("an edit PUTs the WHOLE tree, debounced; a 200 echo becomes the rollback target", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(() => ({ ok: true, tree: served }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ // Not yet — debounced.
+ expect(http.putCalls).toEqual([]);
+ await vi.advanceTimersByTimeAsync(150);
+ expect(http.putCalls).toEqual([{ workspaceId: "ws-a", tree: edited }]);
+ expect(warnings).toEqual([]);
+ });
+
+ it("rapid edits coalesce into ONE debounced PUT of the latest tree", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(50);
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(http.putCalls).toHaveLength(1);
+ expect(http.putCalls[0]?.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ });
+
+ it("a 400 rejection rolls back to the last server tree with a quiet warning", async () => {
+ const served = DEFAULT_LAYOUT;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("present");
+
+ const bad = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(bad);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(served); // rolled back
+ expect(store.providerState).toBe("present"); // the provider is still there
+ expect(warnings.some((w) => w.includes("rolled back"))).toBe(true);
+ });
+
+ it("a 5xx failure rolls back too (present provider, transient error)", async () => {
+ const served = DEFAULT_LAYOUT;
+ let failPut = false;
+ const http = fakeHttp(
+ () => ({ ok: true, tree: served }),
+ () =>
+ failPut
+ ? { ok: false, notFound: false, error: "storage down" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ failPut = true;
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ await vi.advanceTimersByTimeAsync(200);
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a PUT 404 does NOT roll back — the provider vanished mid-session; the edit stays ephemeral", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // The provider unloads before the edit's PUT fires.
+ http.putResult = () => ({ ok: false, notFound: true, error: "No layout provider is active" });
+ const edited = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ store.onTreeChange(edited);
+ await vi.advanceTimersByTimeAsync(200);
+
+ expect(store.tree).toEqual(edited); // the edit STAYS (in-memory)
+ expect(store.providerState).toBe("absent");
+ expect(warnings).toHaveLength(1); // the once-per-absence log, nothing more
+ });
+});
+
+describe("layout store — probe triggers (§2.6)", () => {
+ it("extensions-changed re-probes the active workspace", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent ? { ok: false, notFound: true, error: "No layout provider is active" } : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a layout plugin got enabled on the server
+ store.onExtensionsChanged();
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a workspace switch resets to the default, probes the NEW workspace, and drops queued edits", async () => {
+ const http = fakeHttp((ws) =>
+ ws === "ws-b" ? { ok: true, tree: overlaySidePanels(DEFAULT_LAYOUT, ["vision"]) } : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ // Queue an edit for ws-a, then switch before the debounce fires.
+ store.onTreeChange(overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]));
+ store.onWorkspaceChanged("ws-b");
+ await vi.advanceTimersByTimeAsync(300);
+
+ expect(store.activeWorkspaceId).toBe("ws-b");
+ expect(http.getCalls).toEqual(["ws-a", "ws-b"]);
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["vision"]));
+ // The queued edit never PUTs — it belonged to the old workspace.
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("switching to the SAME workspace is a no-op (no re-probe, no reset)", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ store.onWorkspaceChanged("ws-a");
+ expect(http.getCalls).toEqual(["ws-a"]);
+ });
+});
+
+describe("layout store — one-way localStorage migration (§2.7)", () => {
+ it("boot probe + equal panels + stored key → exactly ONE migration PUT, then the key is dead", async () => {
+ const storage = storageWith(["extensions", "vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]) },
+ ]);
+ // The migrated tree is now the rendered tree.
+ expect(store.tree).toEqual(overlaySidePanels(DEFAULT_LAYOUT, ["extensions", "vision"]));
+
+ // The key is never read again: a later probe PUTs nothing.
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("no stored key → no migration PUT (but the read-once flag is still consumed)", async () => {
+ const storage = storageWith(null);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a custom provider tree (panels differ) is NOT migrated over", async () => {
+ const storage = storageWith(["extensions"]);
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["vision"]);
+ const http = fakeHttp(() => ({ ok: true, tree: custom }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+ expect(store.tree).toEqual(custom);
+ });
+
+ it("migration is boot-only: a non-boot probe never PUTs the stored selection", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a");
+ expect(http.putCalls).toEqual([]);
+ });
+
+ it("a failed migration PUT consumes the flag (best-effort — never retried, never fatal)", async () => {
+ const storage = storageWith(["extensions"]);
+ const http = fakeHttp(
+ () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ () => ({ ok: false, notFound: false, error: "invalid tree" }),
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toHaveLength(1);
+ expect(warnings.some((w) => w.includes("migration"))).toBe(true);
+ // The rendered tree is untouched (the PUT failed).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ await store.probe("ws-a");
+ expect(http.putCalls).toHaveLength(1);
+ });
+
+ it("migration arms even when the boot probe 404s, and applies on the first LATER success", async () => {
+ const storage = storageWith(["extensions"]);
+ let absent = true;
+ const http = fakeHttp(() =>
+ absent ? { ok: false, notFound: true, error: "No layout provider is active" } : { ok: true, tree: DEFAULT_LAYOUT },
+ );
+ const store = makeStore(http, { storage });
+ await store.probe("ws-a", { boot: true });
+ expect(http.putCalls).toEqual([]);
+
+ absent = false;
+ await store.probe("ws-a"); // extensions-changed probe — first success
+ expect(http.putCalls).toEqual([
+ { workspaceId: "ws-a", tree: overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]) },
+ ]);
+ });
+});