summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout')
-rw-r--r--src/features/layout/adapter/abi-port.test.ts97
-rw-r--r--src/features/layout/adapter/abi-port.ts60
-rw-r--r--src/features/layout/adapter/http.test.ts182
-rw-r--r--src/features/layout/adapter/http.ts175
-rw-r--r--src/features/layout/index.ts51
-rw-r--r--src/features/layout/logic/collapse.test.ts120
-rw-r--r--src/features/layout/logic/collapse.ts95
-rw-r--r--src/features/layout/logic/marker.test.ts29
-rw-r--r--src/features/layout/logic/marker.ts46
-rw-r--r--src/features/layout/logic/migration.test.ts68
-rw-r--r--src/features/layout/logic/migration.ts76
-rw-r--r--src/features/layout/logic/panel-keys.test.ts73
-rw-r--r--src/features/layout/logic/panel-keys.ts118
-rw-r--r--src/features/layout/logic/panels-edit.test.ts29
-rw-r--r--src/features/layout/logic/panels-edit.ts33
-rw-r--r--src/features/layout/logic/registry.test.ts148
-rw-r--r--src/features/layout/logic/registry.ts139
-rw-r--r--src/features/layout/logic/seed.test.ts99
-rw-r--r--src/features/layout/logic/seed.ts78
-rw-r--r--src/features/layout/logic/tree-query.ts60
-rw-r--r--src/features/layout/store.svelte.ts316
-rw-r--r--src/features/layout/store.test.ts529
-rw-r--r--src/features/layout/ui/FallbackTile.svelte13
-rw-r--r--src/features/layout/ui/TileLeaf.svelte44
-rw-r--r--src/features/layout/ui/TileNode.svelte37
-rw-r--r--src/features/layout/ui/TilePanels.svelte185
-rw-r--r--src/features/layout/ui/TileRoot.svelte39
-rw-r--r--src/features/layout/ui/TileRoot.test.ts394
-rw-r--r--src/features/layout/ui/TileSplit.svelte130
-rw-r--r--src/features/layout/ui/TileStack.svelte42
-rw-r--r--src/features/layout/ui/fixtures/FixtureA.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureB.svelte6
-rw-r--r--src/features/layout/ui/fixtures/FixtureC.svelte6
-rw-r--r--src/features/layout/ui/panel-motion.ts83
34 files changed, 3606 insertions, 0 deletions
diff --git a/src/features/layout/adapter/abi-port.test.ts b/src/features/layout/adapter/abi-port.test.ts
new file mode 100644
index 0000000..24f9c94
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.test.ts
@@ -0,0 +1,97 @@
+import { DEFAULT_LAYOUT, type LayoutTree } from "@dispatch/layout-contract";
+import { describe, expect, it, vi } from "vitest";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+import { abiTree, createLayoutAbiPort } from "./abi-port";
+
+/**
+ * The ABI `layout` port tests (plan 04 §4.5) — the port is pure composition
+ * over the layout store + the ops HTTP edge, so BOTH deps are fakes (the
+ * outermost edges; nothing internal is mocked — asymmetric testing).
+ */
+
+function fakeStore(
+ overrides: Partial<Pick<LayoutStore, "tree" | "providerState" | "activeWorkspaceId">> = {},
+): LayoutStore {
+ return {
+ get tree(): LayoutTree {
+ return DEFAULT_LAYOUT;
+ },
+ get providerState(): LayoutProviderState {
+ return "present";
+ },
+ get activeWorkspaceId(): string {
+ return "ws-a";
+ },
+ probe: vi.fn(async () => {}),
+ onWorkspaceChanged: vi.fn(),
+ onServerTree: vi.fn(),
+ onTreeChange: vi.fn(),
+ resetToDefault: vi.fn(async () => null),
+ onExtensionsChanged: vi.fn(),
+ onWorkspacePluginsChanged: vi.fn(),
+ ...overrides,
+ } as LayoutStore;
+}
+
+describe("abiTree — the getTree decision (pure)", () => {
+ it("a PRESENT provider exposes the tree", () => {
+ expect(abiTree("present", DEFAULT_LAYOUT)).toBe(DEFAULT_LAYOUT);
+ });
+
+ it("no-provider mode (absent) exposes NULL — the local fallback is not shared state", () => {
+ expect(abiTree("absent", DEFAULT_LAYOUT)).toBeNull();
+ });
+
+ it("an unknown (not-yet-probed) provider exposes NULL too", () => {
+ expect(abiTree("unknown", DEFAULT_LAYOUT)).toBeNull();
+ });
+});
+
+describe("createLayoutAbiPort — applyOps → the ops route", () => {
+ it("posts to the ACTIVE workspace and feeds the returned tree back through the store", async () => {
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const edited: LayoutTree = { ...DEFAULT_LAYOUT };
+ const store = fakeStore();
+ const seen: string[] = [];
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async (workspaceId, o) => {
+ seen.push(workspaceId);
+ return { ok: true, tree: edited, applied: o.length };
+ },
+ });
+
+ const result = await port.applyOps(ops);
+ expect(seen).toEqual(["ws-a"]); // the ACTIVE workspace at CALL time
+ expect(result).toEqual({ ok: true, tree: edited, applied: 1 });
+ // The successful apply echoes the tree into the store (instant local
+ // re-render; the WS broadcast echo is idempotent with this).
+ expect(store.onServerTree).toHaveBeenCalledWith("ws-a", edited);
+ });
+
+ it("a failed batch (typed no-provider error) does NOT touch the store", async () => {
+ const store = fakeStore({ providerState: "absent" });
+ const port = createLayoutAbiPort({
+ store,
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+
+ const result = await port.applyOps([{ type: "removeNode", target: "side" }]);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ expect(store.onServerTree).not.toHaveBeenCalled();
+ });
+
+ it("getTree returns the active tree while the provider is present, null when absent", () => {
+ const port = createLayoutAbiPort({
+ store: fakeStore({ providerState: "present" }),
+ postOps: async () => ({ ok: false, error: "unused" }),
+ });
+ expect(port.getTree()).toBe(DEFAULT_LAYOUT);
+
+ const absent = createLayoutAbiPort({
+ store: fakeStore({ providerState: "absent" }),
+ postOps: async () => ({ ok: false, error: "no layout provider active" }),
+ });
+ expect(absent.getTree()).toBeNull();
+ });
+});
diff --git a/src/features/layout/adapter/abi-port.ts b/src/features/layout/adapter/abi-port.ts
new file mode 100644
index 0000000..69576c6
--- /dev/null
+++ b/src/features/layout/adapter/abi-port.ts
@@ -0,0 +1,60 @@
+import type { ApplyOpsResult, LayoutOp, LayoutTree } from "@dispatch/layout-contract";
+import type { LayoutProviderState, LayoutStore } from "../store.svelte";
+
+/**
+ * The port the frontend-plugin ABI exposes as `api.layout` (plan 04 §4.5).
+ * The composition root builds it with {@link createLayoutAbiPort} and the
+ * plugin host hands it to every `activate(api)` — plugins never see the
+ * store or the HTTP edge, only THIS.
+ */
+export interface LayoutAbiPort {
+ /**
+ * Apply op-vocabulary edits to the ACTIVE workspace's tree through the
+ * provider (`POST /workspaces/:id/layout/ops`). Atomic; the no-provider
+ * 404 maps to the typed `{ ok: false, error: "no layout provider active" }`.
+ */
+ readonly applyOps: (ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+ /** The ACTIVE workspace's tree; `null` in no-provider mode. */
+ readonly getTree: () => LayoutTree | null;
+}
+
+export interface LayoutAbiPortDeps {
+ /** The layout store (the active workspace's tree + provider state). */
+ readonly store: LayoutStore;
+ /** The ops HTTP edge — bound per CALL to the then-active workspace. */
+ readonly postOps: (workspaceId: string, ops: readonly LayoutOp[]) => Promise<ApplyOpsResult>;
+}
+
+/**
+ * Build the ABI's `layout` port over the layout store + the ops HTTP edge
+ * (pure composition — no ambient state; both deps injected). A successful
+ * apply feeds the returned tree back through `onServerTree` so the local
+ * client re-renders immediately (the server's WS broadcast is the
+ * authoritative echo for every client, this one included — applying it twice
+ * is idempotent). The workspace is read at CALL time, so a workspace switch
+ * between calls targets the right tree; a switch mid-flight is guarded by
+ * `onServerTree`'s own workspace filter.
+ */
+export function createLayoutAbiPort(deps: LayoutAbiPortDeps): LayoutAbiPort {
+ return {
+ async applyOps(ops): Promise<ApplyOpsResult> {
+ const workspaceId = deps.store.activeWorkspaceId;
+ const result = await deps.postOps(workspaceId, ops);
+ if (result.ok) {
+ deps.store.onServerTree(workspaceId, result.tree);
+ }
+ return result;
+ },
+ getTree(): LayoutTree | null {
+ // `present` = an active provider is serving the active workspace. In
+ // no-provider (and not-yet-probed) mode the local tree is a fallback,
+ // not shared state — null tells the plugin there is nothing to read.
+ return abiTree(deps.store.providerState, deps.store.tree);
+ },
+ };
+}
+
+/** The getTree decision, pure: only a PRESENT provider's tree is exposed. */
+export function abiTree(state: LayoutProviderState, tree: LayoutTree): LayoutTree | null {
+ return state === "present" ? tree : null;
+}
diff --git a/src/features/layout/adapter/http.test.ts b/src/features/layout/adapter/http.test.ts
new file mode 100644
index 0000000..30e194a
--- /dev/null
+++ b/src/features/layout/adapter/http.test.ts
@@ -0,0 +1,182 @@
+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: [], historyEpoch: 0, 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 });
+ });
+});
+
+describe("createLayoutHttp — postOps (plan 04 §4.2)", () => {
+ it("POSTs { ops } to the ops route and unwraps { tree, applied }", async () => {
+ const capture: { url?: string; init?: RequestInit } = {};
+ const ops = [{ type: "ensurePanel", panels: "side", view: "demo" }] as const;
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(200, { tree: DEFAULT_LAYOUT, applied: 1 }, capture),
+ );
+ const result = await http.postOps("ws-a", ops);
+ expect(result).toEqual({ ok: true, tree: DEFAULT_LAYOUT, applied: 1 });
+ expect(capture.url).toBe(`${LAYOUT_URL}/ops`);
+ expect(capture.init?.method).toBe("POST");
+ expect(capture.init?.body).toBe(JSON.stringify({ ops }));
+ });
+
+ it("maps a 404 to the typed no-provider error (the ABI's documented failure)", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(404, {}));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "no layout provider active" });
+ });
+
+ it("maps a 400 { error, failedOp } to the atomic-reject failure", async () => {
+ const http = createLayoutHttp(
+ "http://backend.test",
+ fetchReturning(400, { error: "op 1: no such node", failedOp: 1 }),
+ );
+ const result = await http.postOps("ws-a", [{ type: "removeNode", target: "x" }]);
+ expect(result).toEqual({ ok: false, error: "op 1: no such node", failedOp: 1 });
+ });
+
+ it("a malformed 200 (no tree in the body) is a failure, never a silent ok", async () => {
+ const http = createLayoutHttp("http://backend.test", fetchReturning(200, { applied: 2 }));
+ const result = await http.postOps("ws-a", []);
+ expect(result).toMatchObject({ ok: false });
+ });
+
+ it("a network failure degrades to a typed failure (never throws)", async () => {
+ const http = createLayoutHttp("http://backend.test", async () => {
+ throw new TypeError("fetch failed");
+ });
+ const result = await http.postOps("ws-a", []);
+ expect(result).toEqual({ ok: false, error: "fetch failed" });
+ });
+});
diff --git a/src/features/layout/adapter/http.ts b/src/features/layout/adapter/http.ts
new file mode 100644
index 0000000..ff15a70
--- /dev/null
+++ b/src/features/layout/adapter/http.ts
@@ -0,0 +1,175 @@
+import type { ApplyOpsResult, LayoutOp, 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; the plugins
+ * modal's "Reset layout to default" button — plan 04 §4.4)
+ * - `POST /workspaces/:id/layout/ops` body `{ ops }` → 200 `{ tree, applied }`
+ * / 400 `{ error, failedOp? }` / 404 no provider (plan 04 §4.2 — the FE
+ * ABI's `layout.applyOps` door)
+ *
+ * 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>;
+ /**
+ * The plan-04 ops door (§4.2): apply op-vocabulary edits through the
+ * provider. The result IS the contract's `ApplyOpsResult` — the 404
+ * (no provider) maps to the typed `{ ok: false, error: "no layout
+ * provider active" }` failure the ABI documents.
+ */
+ postOps(workspaceId: string, ops: readonly LayoutOp[]): Promise<ApplyOpsResult>;
+}
+
+/** 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",
+ };
+ }
+ },
+
+ async postOps(workspaceId, ops): Promise<ApplyOpsResult> {
+ try {
+ const res = await fetchImpl(
+ `${httpBase}/workspaces/${encodeURIComponent(workspaceId)}/layout/ops`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ops }),
+ },
+ );
+ // The no-provider 404 is the ABI's typed signal (plan 04 §4.5).
+ if (res.status === 404) {
+ return { ok: false, error: "no layout provider active" };
+ }
+ if (!res.ok) return await opsError(res);
+ const body = (await res.json()) as { tree?: unknown; applied?: unknown };
+ if (body === null || typeof body !== "object" || body.tree === undefined)
+ return { ok: false, error: "Malformed layout response" };
+ return {
+ ok: true,
+ tree: body.tree as LayoutTree,
+ applied: typeof body.applied === "number" ? body.applied : ops.length,
+ };
+ } catch (err) {
+ return {
+ ok: false,
+ error: err instanceof Error ? err.message : "Layout request failed",
+ };
+ }
+ },
+ };
+}
+
+/** Read an ops-failure body (`{ error, failedOp? }`), tolerating junk. */
+async function opsError(res: Response): Promise<ApplyOpsResult> {
+ try {
+ const body = (await res.json()) as { error?: unknown; failedOp?: unknown };
+ const error =
+ typeof body.error === "string" && body.error.length > 0 ? body.error : `HTTP ${res.status}`;
+ return {
+ ok: false,
+ error,
+ ...(typeof body.failedOp === "number" ? { failedOp: body.failedOp } : {}),
+ };
+ } catch {
+ return { ok: false, error: `HTTP ${res.status}` };
+ }
+}
diff --git a/src/features/layout/index.ts b/src/features/layout/index.ts
new file mode 100644
index 0000000..8a464fe
--- /dev/null
+++ b/src/features/layout/index.ts
@@ -0,0 +1,51 @@
+/**
+ * The layout feature — a generic tile-tree renderer + the view registry.
+ *
+ * The frontend stops *having* a layout and starts *rendering* one: the tree is
+ * data (`@dispatch/layout-contract`), the renderer walks it, and every view a
+ * leaf mounts comes from the registry. Chrome (top bar, alerts, modals) stays
+ * OUTSIDE the tree — the tree owns the content region.
+ */
+
+export {
+ abiTree,
+ createLayoutAbiPort,
+ type LayoutAbiPort,
+ type LayoutAbiPortDeps,
+} from "./adapter/abi-port";
+export { createLayoutHttp, type LayoutHttp, type LayoutHttpResult } from "./adapter/http";
+export {
+ bootDefaultOpen,
+ type CollapseState,
+ type CollapseStore,
+ type CollapseStoreOptions,
+ createCollapseStore,
+ parsePersistedOpen,
+ 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,
+ listPanelViews,
+ listViews,
+ type RegisteredView,
+ registerPluginView,
+ registerView,
+ unregisterView,
+ unregisterViewsByPlugin,
+ type ViewComponent,
+ type ViewRegistration,
+ viewRegistry,
+} 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. */
+export const manifest = {
+ name: "layout",
+ description: "Tile-tree renderer + view registry",
+} as const;
diff --git a/src/features/layout/logic/collapse.test.ts b/src/features/layout/logic/collapse.test.ts
new file mode 100644
index 0000000..77e9f3c
--- /dev/null
+++ b/src/features/layout/logic/collapse.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from "vitest";
+import {
+ bootDefaultOpen,
+ createCollapseStore,
+ parsePersistedOpen,
+ SIDEBAR_OPEN_KEY,
+ WIDE_BREAKPOINT,
+} from "./collapse";
+
+function fakeStorage(): { storage: Storage; raw(key: string): string | null } {
+ const map = new Map<string, string>();
+ const storage: Storage = {
+ get length() {
+ return map.size;
+ },
+ clear: () => map.clear(),
+ getItem: (key) => map.get(key) ?? null,
+ key: () => null,
+ removeItem: (key) => map.delete(key),
+ setItem: (key, value) => void map.set(key, value),
+ };
+ return { storage, raw: (key) => map.get(key) ?? null };
+}
+
+describe("bootDefaultOpen", () => {
+ it("defaults to open on wide screens (>= 1024px) and closed on narrow ones", () => {
+ expect(bootDefaultOpen(WIDE_BREAKPOINT)).toBe(true);
+ expect(bootDefaultOpen(1440)).toBe(true);
+ expect(bootDefaultOpen(1023)).toBe(false);
+ expect(bootDefaultOpen(0)).toBe(false);
+ });
+
+ it("defaults to open when the viewport is unknown (SSR/tests)", () => {
+ expect(bootDefaultOpen(undefined)).toBe(true);
+ });
+});
+
+describe("parsePersistedOpen", () => {
+ it("accepts today's bare JSON boolean format", () => {
+ expect(parsePersistedOpen("true")).toBe(true);
+ expect(parsePersistedOpen("false")).toBe(false);
+ });
+
+ it("ignores null, malformed JSON, and non-boolean values", () => {
+ expect(parsePersistedOpen(null)).toBeNull();
+ expect(parsePersistedOpen("{")).toBeNull();
+ expect(parsePersistedOpen('"true"')).toBeNull();
+ expect(parsePersistedOpen("{}")).toBeNull();
+ });
+});
+
+describe("createCollapseStore", () => {
+ it("uses the viewport default when nothing is persisted", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ expect(store.isOpen("side")).toBe(true);
+ const narrow = createCollapseStore({ viewportWidth: 800 });
+ expect(narrow.isOpen("side")).toBe(false);
+ });
+
+ it("restores the persisted boolean over the viewport default", () => {
+ const { storage } = fakeStorage();
+ storage.setItem(SIDEBAR_OPEN_KEY, "false");
+ const store = createCollapseStore({ storage, viewportWidth: 1440 });
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("persists toggles as today's bare JSON boolean (same key, same bytes)", () => {
+ const { storage, raw } = fakeStorage();
+ const store = createCollapseStore({ storage, viewportWidth: 1024 });
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("false");
+ store.toggle("side");
+ expect(raw(SIDEBAR_OPEN_KEY)).toBe("true");
+ });
+
+ it("tracks per-node state reactively", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ const seen: boolean[] = [];
+ const unsubscribe = store.subscribe((state) =>
+ seen.push(state.openByNode.side ?? state.defaultOpen),
+ );
+ try {
+ store.setOpen("side", false);
+ store.setOpen("side", true);
+ } finally {
+ unsubscribe();
+ }
+ expect(seen).toEqual([true, false, true]);
+ expect(store.isOpen("side")).toBe(true);
+ });
+
+ it("an untracked node falls back to the default", () => {
+ const store = createCollapseStore({ viewportWidth: 800 });
+ expect(store.isOpen("other")).toBe(false);
+ });
+
+ it("survives storage failures (quota errors are swallowed)", () => {
+ const broken: Storage = {
+ get length() {
+ return 0;
+ },
+ clear: () => undefined,
+ getItem: () => null,
+ key: () => null,
+ removeItem: () => undefined,
+ setItem: () => {
+ throw new Error("quota exceeded");
+ },
+ };
+ const store = createCollapseStore({ storage: broken, viewportWidth: 1024 });
+ expect(() => store.toggle("side")).not.toThrow();
+ expect(store.isOpen("side")).toBe(false);
+ });
+
+ it("works without storage (session-only chrome state)", () => {
+ const store = createCollapseStore({ viewportWidth: 1024 });
+ store.toggle("side");
+ expect(store.isOpen("side")).toBe(false);
+ });
+});
diff --git a/src/features/layout/logic/collapse.ts b/src/features/layout/logic/collapse.ts
new file mode 100644
index 0000000..28e90b4
--- /dev/null
+++ b/src/features/layout/logic/collapse.ts
@@ -0,0 +1,95 @@
+/**
+ * Collapse chrome state — the open/closed state of the tree's collapsible
+ * split children (today: the sidebar). Renderer chrome, NOT tree data
+ * (layout-00 decision 3): tree nodes merely declare `collapsible`; this store
+ * owns the per-node state, persisted device-locally.
+ *
+ * Persistence is BYTE-COMPATIBLE with today: the same `dispatch.sidebar.open`
+ * key holding a JSON boolean, defaulting to open on wide screens (>= 1024px,
+ * Tailwind `lg`). Today's UI has exactly one collapsible node, and the
+ * persisted boolean tracks the toggled node's state — identical bytes and
+ * behavior. A future with several collapsible nodes migrates the format in
+ * plan 02, not here.
+ */
+
+import { type Readable, writable } from "svelte/store";
+
+/** The localStorage key — unchanged from today (no migration). */
+export const SIDEBAR_OPEN_KEY = "dispatch.sidebar.open";
+/** Tailwind `lg`: today's boot default is open on wide screens. */
+export const WIDE_BREAKPOINT = 1024;
+
+export interface CollapseState {
+ /** Explicit per-node open state (node id → open). Absent = the default. */
+ readonly openByNode: Readonly<Record<string, boolean>>;
+ /** The boot default (persisted boolean, else viewport width >= 1024). */
+ readonly defaultOpen: boolean;
+}
+
+export interface CollapseStore extends Readable<CollapseState> {
+ /** Is the named node open? (explicit state, else the default) */
+ isOpen(nodeId: string): boolean;
+ setOpen(nodeId: string, open: boolean): void;
+ toggle(nodeId: string): void;
+}
+
+export interface CollapseStoreOptions {
+ /** Injected storage (the app store's `Storage`; undefined → session-only). */
+ readonly storage?: Storage | undefined;
+ /** Viewport width at boot (today: `window.innerWidth`). Undefined → default open. */
+ readonly viewportWidth?: number | undefined;
+ /** Storage key override (tests). Defaults to `dispatch.sidebar.open`. */
+ readonly key?: string | undefined;
+}
+
+/** The boot default: open on wide screens (narrow screens start closed). */
+export function bootDefaultOpen(viewportWidth: number | undefined): boolean {
+ if (viewportWidth === undefined) return true;
+ return viewportWidth >= WIDE_BREAKPOINT;
+}
+
+/** Parse the persisted value. Today's format is a bare JSON boolean; anything else is ignored. */
+export function parsePersistedOpen(raw: string | null): boolean | null {
+ if (raw === null) return null;
+ try {
+ const parsed: unknown = JSON.parse(raw);
+ return typeof parsed === "boolean" ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function createCollapseStore(options?: CollapseStoreOptions): CollapseStore {
+ const key = options?.key ?? SIDEBAR_OPEN_KEY;
+ const storage = options?.storage;
+ const persisted = parsePersistedOpen(storage === undefined ? null : storage.getItem(key));
+ const defaultOpen = persisted ?? bootDefaultOpen(options?.viewportWidth);
+
+ let openByNode: Record<string, boolean> = {};
+ const store = writable<CollapseState>({ openByNode, defaultOpen });
+
+ function set(nodeId: string, open: boolean): void {
+ openByNode = { ...openByNode, [nodeId]: open };
+ store.set({ openByNode, defaultOpen });
+ // Persist the toggled node's state as today's bare boolean. With one
+ // collapsible node (always, today) this is exactly today's behavior.
+ if (storage !== undefined) {
+ try {
+ storage.setItem(key, JSON.stringify(open));
+ } catch {
+ // Swallow quota / write errors — persistence is best-effort.
+ }
+ }
+ }
+
+ function isOpen(nodeId: string): boolean {
+ return openByNode[nodeId] ?? defaultOpen;
+ }
+
+ return {
+ subscribe: store.subscribe,
+ isOpen,
+ setOpen: (nodeId, open) => set(nodeId, open),
+ toggle: (nodeId) => set(nodeId, !isOpen(nodeId)),
+ };
+}
diff --git a/src/features/layout/logic/marker.test.ts b/src/features/layout/logic/marker.test.ts
new file mode 100644
index 0000000..53b91b0
--- /dev/null
+++ b/src/features/layout/logic/marker.test.ts
@@ -0,0 +1,29 @@
+import type { TileMarker } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { markerOutline, markerStyle } from "./marker";
+
+describe("markerOutline — the diagnostic tile-border mapping", () => {
+ it("main → a 4px solid red outline (the main tile's marker)", () => {
+ expect(markerOutline("main")).toBe("4px solid red");
+ });
+
+ it("side → a 4px solid green outline (the side tile's marker)", () => {
+ expect(markerOutline("side")).toBe("4px solid green");
+ });
+
+ it("no marker → no outline (existing trees render exactly as before)", () => {
+ expect(markerOutline(undefined)).toBeUndefined();
+ });
+});
+
+describe("markerStyle — the complete inline style (outline + inset offset)", () => {
+ it("insets the outline so it draws INSIDE the tile's box (never clipped at screen edges)", () => {
+ expect(markerStyle("main")).toBe("outline: 4px solid red; outline-offset: -4px");
+ expect(markerStyle("side")).toBe("outline: 4px solid green; outline-offset: -4px");
+ });
+
+ it("no/unknown marker → NO style at all (unmarked nodes carry zero outline CSS)", () => {
+ expect(markerStyle(undefined)).toBeUndefined();
+ expect(markerStyle("purple" as unknown as TileMarker)).toBeUndefined();
+ });
+});
diff --git a/src/features/layout/logic/marker.ts b/src/features/layout/logic/marker.ts
new file mode 100644
index 0000000..46259e0
--- /dev/null
+++ b/src/features/layout/logic/marker.ts
@@ -0,0 +1,46 @@
+/**
+ * Diagnostic tile-border markers (the contract's additive `TileMarker`
+ * presentation field) → the inline outline the renderer draws. Pure
+ * decision logic — the .svelte tile components just apply the returned
+ * style value.
+ *
+ * INLINE STYLES, deliberately: a marker value arrives in TREE DATA at
+ * runtime, and Tailwind only generates classes it sees in source files — a
+ * dynamic class from tree data would silently render nothing. An outline
+ * (not a border) is used so the diagnostic draws WITHOUT changing any
+ * tile's layout/size (4px solid, clearly visible).
+ *
+ * The outline is INSET (`outline-offset: -4px`) so it draws INSIDE the
+ * tile's box: tiles span their containers edge-to-edge, so the default
+ * (outside) outline overflows the visible area and is clipped away at the
+ * screen edges. The views inside the tiles have no opaque full-bleed
+ * background, so the inset ring stays on top of the tile's content.
+ *
+ * The marker is purely presentational: unknown/absent → no outline
+ * (existing trees render exactly as before), and no other behavior is
+ * attached to it anywhere in the renderer.
+ */
+
+import type { TileMarker } from "@dispatch/layout-contract";
+
+/** The diagnostic outline style for a marker — `undefined` = draw nothing. */
+export function markerOutline(marker: TileMarker | undefined): string | undefined {
+ if (marker === "main") return "4px solid red";
+ if (marker === "side") return "4px solid green";
+ return undefined;
+}
+
+/** The inset that keeps the outline INSIDE the tile's box (see the header note). */
+export const MARKER_OUTLINE_OFFSET = "-4px";
+
+/**
+ * The COMPLETE inline style for a marked element — outline + inset offset in
+ * one declaration — or `undefined` when unmarked/unknown, so unmarked nodes
+ * carry no outline CSS at all (existing trees render exactly as before).
+ */
+export function markerStyle(marker: TileMarker | undefined): string | undefined {
+ const outline = markerOutline(marker);
+ return outline === undefined
+ ? undefined
+ : `outline: ${outline}; outline-offset: ${MARKER_OUTLINE_OFFSET}`;
+}
diff --git a/src/features/layout/logic/migration.test.ts b/src/features/layout/logic/migration.test.ts
new file mode 100644
index 0000000..9eeeda0
--- /dev/null
+++ b/src/features/layout/logic/migration.test.ts
@@ -0,0 +1,68 @@
+import { DEFAULT_LAYOUT } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+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/logic/panel-keys.test.ts b/src/features/layout/logic/panel-keys.test.ts
new file mode 100644
index 0000000..b45671a
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import { initialPanelKeys, reconcilePanelKeys, swappedPanels } from "./panel-keys";
+
+describe("initialPanelKeys", () => {
+ it("numbers the panels in order and seeds past them", () => {
+ expect(initialPanelKeys(["tabs", "model"])).toEqual({
+ views: ["tabs", "model"],
+ keys: [0, 1],
+ seed: 2,
+ });
+ });
+});
+
+describe("reconcilePanelKeys", () => {
+ const start = initialPanelKeys(["tabs", "model", "usage"]); // keys 0 1 2
+
+ it("closing the MIDDLE panel drops the middle key (not the last)", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "usage"]);
+ expect(next.keys).toEqual([0, 2]);
+ expect(next.seed).toBe(3);
+ });
+
+ it("adding a panel gives only the new one a fresh key", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "model", "usage", "lsp"]);
+ expect(next.keys).toEqual([0, 1, 2, 3]);
+ expect(next.seed).toBe(4);
+ });
+
+ it("a dropdown swap keeps every key positional", () => {
+ const next = reconcilePanelKeys(start, ["tabs", "lsp", "usage"]);
+ expect(next.keys).toEqual([0, 1, 2]);
+ });
+
+ it("handles duplicate views and never reuses a key", () => {
+ const dup = initialPanelKeys(["a", "b", "a"]); // 0 1 2
+ const removed = reconcilePanelKeys(dup, ["a", "a"]);
+ expect(removed.keys).toEqual([0, 2]);
+ const added = reconcilePanelKeys(removed, ["a", "a", "a"]);
+ expect(added.keys).toEqual([0, 2, 3]);
+ expect(new Set(added.keys).size).toBe(3);
+ });
+
+ it("a ✕ hint picks the exact panel when identical views make it ambiguous", () => {
+ const twins = initialPanelKeys(["model", "usage", "usage"]); // 0 1 2
+ expect(reconcilePanelKeys(twins, ["model", "usage"]).keys).toEqual([0, 1]); // no hint
+ expect(reconcilePanelKeys(twins, ["model", "usage"], 1).keys).toEqual([0, 2]);
+ });
+
+ it("ignores a stale hint that does not describe the change", () => {
+ // The tree changed some other way (e.g. a remote edit) — fall back to LCS.
+ expect(reconcilePanelKeys(start, ["tabs", "model"], 0).keys).toEqual([0, 1]);
+ expect(reconcilePanelKeys(start, ["tabs", "model", "usage"], 1).keys).toEqual([0, 1, 2]);
+ });
+
+ it("an unrelated replacement of different length gets all-fresh keys", () => {
+ const next = reconcilePanelKeys(start, ["x"]);
+ expect(next.keys).toEqual([3]);
+ });
+});
+
+describe("swappedPanels", () => {
+ it("reports a surviving panel whose view changed", () => {
+ const prev = initialPanelKeys(["tabs", "model"]);
+ const next = reconcilePanelKeys(prev, ["tabs", "usage"]);
+ expect([...swappedPanels(prev, next)]).toEqual([[1, { from: "model", to: "usage" }]]);
+ });
+
+ it("does not report adds or removes", () => {
+ const prev = initialPanelKeys(["tabs", "model", "usage"]);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, ["tabs", "usage"])).size).toBe(0);
+ expect(swappedPanels(prev, reconcilePanelKeys(prev, [...prev.views, "lsp"])).size).toBe(0);
+ });
+});
diff --git a/src/features/layout/logic/panel-keys.ts b/src/features/layout/logic/panel-keys.ts
new file mode 100644
index 0000000..76a053f
--- /dev/null
+++ b/src/features/layout/logic/panel-keys.ts
@@ -0,0 +1,118 @@
+/**
+ * Stable panel keys — the PURE identity scheme behind the panel height
+ * animation.
+ *
+ * Panel leaves carry no required id, so the panels list can't be keyed by the
+ * tree. Keying by INDEX is wrong for removal: closing the middle of three
+ * panels would re-use the middle DOM node for the third view and animate the
+ * LAST panel out. This reconciles a stable numeric key per panel across tree
+ * updates, from the view ids alone:
+ *
+ * - same length → keys stay positional (a dropdown SWAP keeps its panel —
+ * the panel morphs its height to the new view instead of leaving).
+ * - length changed → the longest common subsequence of view ids keeps its
+ * keys; every unmatched new panel gets a fresh key (an ADD), every
+ * unmatched old key disappears (a REMOVE).
+ *
+ * Zero Svelte, zero DOM.
+ */
+
+export interface PanelKeys {
+ /** View id per panel, in order. */
+ readonly views: readonly string[];
+ /** Stable key per panel, aligned with `views`. */
+ readonly keys: readonly number[];
+ /** The next fresh key. */
+ readonly seed: number;
+}
+
+/** Keys for a freshly mounted panels list. */
+export function initialPanelKeys(views: readonly string[]): PanelKeys {
+ return { views: [...views], keys: views.map((_, i) => i), seed: views.length };
+}
+
+/**
+ * Reconcile `prev` against the next view list (see the module doc).
+ *
+ * `removedIndex` is an optional HINT from a local ✕ click: when the next list
+ * is exactly `prev` minus that index, that exact panel is the one removed.
+ * Without it, closing one of two identical adjacent views is ambiguous to the
+ * subsequence match (it would drop the later one).
+ */
+export function reconcilePanelKeys(
+ prev: PanelKeys,
+ nextViews: readonly string[],
+ removedIndex?: number,
+): PanelKeys {
+ if (removedIndex !== undefined && isRemovalAt(prev.views, nextViews, removedIndex)) {
+ return {
+ views: [...nextViews],
+ keys: prev.keys.filter((_, i) => i !== removedIndex),
+ seed: prev.seed,
+ };
+ }
+ if (nextViews.length === prev.views.length) {
+ return { views: [...nextViews], keys: [...prev.keys], seed: prev.seed };
+ }
+ const matched = lcsMatch(prev.views, nextViews);
+ let seed = prev.seed;
+ const keys = nextViews.map((_, j) => {
+ const i = matched.get(j);
+ if (i !== undefined) return prev.keys[i] ?? seed++;
+ return seed++;
+ });
+ return { views: [...nextViews], keys, seed };
+}
+
+/**
+ * The views whose panel SURVIVED but now mounts a different view (a swap):
+ * key → { from, to }. Adds/removes are not swaps.
+ */
+export function swappedPanels(
+ prev: PanelKeys,
+ next: PanelKeys,
+): Map<number, { from: string; to: string }> {
+ const before = new Map(prev.keys.map((k, i) => [k, prev.views[i] ?? ""]));
+ const out = new Map<number, { from: string; to: string }>();
+ next.keys.forEach((k, i) => {
+ const from = before.get(k);
+ const to = next.views[i] ?? "";
+ if (from !== undefined && from !== to) out.set(k, { from, to });
+ });
+ return out;
+}
+
+function isRemovalAt(prev: readonly string[], next: readonly string[], index: number): boolean {
+ if (index < 0 || index >= prev.length || next.length !== prev.length - 1) return false;
+ return next.every((v, j) => v === prev[j < index ? j : j + 1]);
+}
+
+/** LCS alignment: next index → prev index for every matched element. */
+function lcsMatch(a: readonly string[], b: readonly string[]): Map<number, number> {
+ const n = a.length;
+ const m = b.length;
+ // dp[i][j] = LCS length of a[i..] and b[j..]
+ const dp: number[][] = Array.from({ length: n + 1 }, () => new Array<number>(m + 1).fill(0));
+ for (let i = n - 1; i >= 0; i--) {
+ for (let j = m - 1; j >= 0; j--) {
+ const row = dp[i] as number[];
+ const below = dp[i + 1] as number[];
+ row[j] = a[i] === b[j] ? (below[j + 1] ?? 0) + 1 : Math.max(below[j] ?? 0, row[j + 1] ?? 0);
+ }
+ }
+ const out = new Map<number, number>();
+ let i = 0;
+ let j = 0;
+ while (i < n && j < m) {
+ if (a[i] === b[j]) {
+ out.set(j, i);
+ i++;
+ j++;
+ } else if ((dp[i + 1]?.[j] ?? 0) >= (dp[i]?.[j + 1] ?? 0)) {
+ i++;
+ } else {
+ j++;
+ }
+ }
+ return out;
+}
diff --git a/src/features/layout/logic/panels-edit.test.ts b/src/features/layout/logic/panels-edit.test.ts
new file mode 100644
index 0000000..b4c3099
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from "vitest";
+import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "./panels-edit";
+
+describe("panel edit → op mapping", () => {
+ it("maps a dropdown swap to a replaceView on the panel leaf's path", () => {
+ expect(swapPanelOp([1], 2, "model")).toEqual({
+ type: "replaceView",
+ target: [1, 2],
+ view: "model",
+ });
+ });
+
+ it("maps the + button to an append insertPanel", () => {
+ expect(addPanelOp(["titlebar"], "tasks")).toEqual({
+ type: "insertPanel",
+ panels: ["titlebar"],
+ view: "tasks",
+ });
+ });
+
+ it("maps the ✕ button to a removeNode on the panel leaf's path", () => {
+ expect(removePanelOp([1], 0)).toEqual({ type: "removeNode", target: [1, 0] });
+ });
+
+ it("the + button pre-selects the first eligible view (or null when none)", () => {
+ expect(firstPanelView([{ id: "tabs" }, { id: "model" }])).toBe("tabs");
+ expect(firstPanelView([])).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/panels-edit.ts b/src/features/layout/logic/panels-edit.ts
new file mode 100644
index 0000000..8a56859
--- /dev/null
+++ b/src/features/layout/logic/panels-edit.ts
@@ -0,0 +1,33 @@
+/**
+ * Panel-edit → tree-op mapping: the pure decision logic behind TilePanels'
+ * dropdown swap / add / remove buttons. The component builds the ops; the
+ * renderer's owner (TileRoot → onTreeChange) applies them. Zero Svelte, zero
+ * DOM.
+ */
+
+import type { LayoutOp, Path } from "@dispatch/layout-contract";
+
+/** Swap the view a panel leaf mounts (panel `index` of the panels node at `path`). */
+export function swapPanelOp(path: Path, index: number, view: string): LayoutOp {
+ return { type: "replaceView", target: [...path, index], view };
+}
+
+/** Add a panel mounting `view` (appended to the panels node at `path`). */
+export function addPanelOp(path: Path, view: string): LayoutOp {
+ return { type: "insertPanel", panels: path, view };
+}
+
+/** Remove the panel at `index` from the panels node at `path`. */
+export function removePanelOp(path: Path, index: number): LayoutOp {
+ return { type: "removeNode", target: [...path, index] };
+}
+
+/**
+ * The view a new panel starts with. The tree schema has no "empty panel"
+ * state (a leaf's `view` is required — today's "Select a view" placeholder has
+ * no tree representation), so the + button pre-selects the first panel-eligible
+ * view. Returns null when nothing is eligible (the + button hides).
+ */
+export function firstPanelView(views: readonly { readonly id: string }[]): string | null {
+ return views[0]?.id ?? null;
+}
diff --git a/src/features/layout/logic/registry.test.ts b/src/features/layout/logic/registry.test.ts
new file mode 100644
index 0000000..e5e947c
--- /dev/null
+++ b/src/features/layout/logic/registry.test.ts
@@ -0,0 +1,148 @@
+import type { Component } from "svelte";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getView,
+ listPanelViews,
+ listViews,
+ registerPluginView,
+ registerView,
+ unregisterView,
+ unregisterViewsByPlugin,
+ type ViewRegistration,
+ viewRegistry,
+} from "./registry";
+
+const fakeComponent: Component = () => {
+ throw new Error("fixture component should never be constructed");
+};
+
+function registration(overrides?: Partial<ViewRegistration>): ViewRegistration {
+ return { id: "test-view", label: "Test View", component: fakeComponent, ...overrides };
+}
+
+const registeredIds = (): string[] => listViews().map((v) => v.id);
+
+afterEach(() => {
+ for (const id of registeredIds()) unregisterView(id);
+});
+
+describe("view registry", () => {
+ it("registers a view and exposes it via getView/listViews", () => {
+ registerView(registration());
+ expect(getView("test-view")?.label).toBe("Test View");
+ expect(registeredIds()).toContain("test-view");
+ });
+
+ it("throws loudly on a duplicate id", () => {
+ registerView(registration());
+ expect(() => registerView(registration({ label: "Other" }))).toThrow(/duplicate view id/);
+ });
+
+ it("normalizes panelEligible to true by default and respects false", () => {
+ registerView(registration({ id: "a" }));
+ registerView(registration({ id: "b", panelEligible: false }));
+ expect(getView("a")?.panelEligible).toBe(true);
+ expect(getView("b")?.panelEligible).toBe(false);
+ expect(listPanelViews().map((v) => v.id)).toContain("a");
+ expect(listPanelViews().map((v) => v.id)).not.toContain("b");
+ });
+
+ it("keeps pluginId when given", () => {
+ registerView(registration({ id: "plugin-view", pluginId: "my-plugin" }));
+ expect(getView("plugin-view")?.pluginId).toBe("my-plugin");
+ });
+
+ it("unregisterView removes the view (idempotent for unknown ids)", () => {
+ registerView(registration({ id: "gone" }));
+ unregisterView("gone");
+ expect(getView("gone")).toBeUndefined();
+ expect(() => unregisterView("never-registered")).not.toThrow();
+ });
+
+ it("is reactive: subscribers see registrations and unregistrations", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => seen.push(views.map((v) => v.id)));
+ try {
+ registerView(registration({ id: "reactive-a" }));
+ registerView(registration({ id: "reactive-b" }));
+ unregisterView("reactive-a");
+ } finally {
+ unsubscribe();
+ }
+ expect(seen[0]).toEqual([]);
+ expect(seen.at(-1)).toEqual(["reactive-b"]);
+ });
+
+ it("preserves registration order in listViews", () => {
+ registerView(registration({ id: "z-first" }));
+ registerView(registration({ id: "a-second" }));
+ const ids = registeredIds();
+ expect(ids.indexOf("z-first")).toBeLessThan(ids.indexOf("a-second"));
+ });
+});
+
+// ── Plugin-view registration (plan 03) — forced attribution + the sweep ─────
+
+describe("registerPluginView / unregisterViewsByPlugin (plan 03)", () => {
+ it("forces the importing plugin's pluginId attribution", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ expect(getView("abi-view")?.pluginId).toBe("my-plugin");
+ dispose();
+ expect(getView("abi-view")).toBeUndefined();
+ });
+
+ it("returns a working per-view disposer (the plugin deactivate contract)", () => {
+ const dispose = registerPluginView(
+ { id: "abi-view-2", label: "ABI View", component: fakeComponent },
+ "my-plugin",
+ );
+ dispose();
+ dispose(); // idempotent
+ expect(getView("abi-view-2")).toBeUndefined();
+ });
+
+ it("the sweep removes exactly that plugin's views (registry attribution)", () => {
+ registerPluginView({ id: "p1-a", label: "A", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p1-b", label: "B", component: fakeComponent }, "plugin-1");
+ registerPluginView({ id: "p2-a", label: "C", component: fakeComponent }, "plugin-2");
+ registerView(registration({ id: "builtin" })); // no pluginId — never swept
+
+ unregisterViewsByPlugin("plugin-1");
+ expect(getView("p1-a")).toBeUndefined();
+ expect(getView("p1-b")).toBeUndefined();
+ expect(getView("p2-a")).not.toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+
+ unregisterViewsByPlugin("plugin-2");
+ expect(getView("p2-a")).toBeUndefined();
+ expect(getView("builtin")).not.toBeUndefined();
+ });
+
+ it("the sweep is idempotent and a no-op for an unknown plugin", () => {
+ unregisterViewsByPlugin("nobody");
+ registerPluginView({ id: "p3-a", label: "A", component: fakeComponent }, "plugin-3");
+ unregisterViewsByPlugin("plugin-3");
+ expect(() => unregisterViewsByPlugin("plugin-3")).not.toThrow();
+ });
+
+ it("a duplicate id from a plugin throws LOUDLY (the driver marks it failed)", () => {
+ registerView(registration({ id: "taken" }));
+ expect(() =>
+ registerPluginView({ id: "taken", label: "Taken", component: fakeComponent }, "my-plugin"),
+ ).toThrow(/duplicate view id/);
+ });
+
+ it("sweep removals are visible to registry subscribers (reactive)", () => {
+ const seen: string[][] = [];
+ const unsubscribe = viewRegistry.subscribe((views) => {
+ seen.push(views.map((v) => v.id));
+ });
+ registerPluginView({ id: "reactive", label: "R", component: fakeComponent }, "p");
+ unregisterViewsByPlugin("p");
+ unsubscribe();
+ expect(seen.at(-1)).not.toContain("reactive");
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
new file mode 100644
index 0000000..bcd49e0
--- /dev/null
+++ b/src/features/layout/logic/registry.ts
@@ -0,0 +1,139 @@
+/**
+ * The view registry — the map from view id → component that tile leaves mount.
+ *
+ * Every view the renderer can place (the sidebar views + chat / composer /
+ * message-queue today; plugin views from plan 03 later) registers here. The
+ * registry is REACTIVE (a Svelte store): plan 03's late registrations re-render
+ * open dropdowns, and the tree renderer resolves leaves through it per render.
+ *
+ * An unknown view id in a leaf is NEVER a crash — backend-owned trees (plan 02)
+ * can name views a given client lacks; the renderer shows a fallback tile.
+ *
+ * This module holds a small amount of reactive state by design (the plan places
+ * the registry in `logic/` with Svelte-store reactivity). It is NOT decision
+ * logic — no DOM, no effects.
+ */
+
+import type { Component } from "svelte";
+import { type Readable, writable } from "svelte/store";
+
+/**
+ * The props type of a registered view component. The composition root hands
+ * every wrapper its `ports` through this bag (see `src/app/view-ports.ts`);
+ * fixture/generic components may ignore it.
+ *
+ * The registry is a HETEROGENEOUS component bag (the plan types it `Component`
+ * outright), so its props are opaque `Record<string, any>` — the wrappers
+ * re-narrow to their concrete props at the composition-root seam.
+ */
+export type ViewComponent =
+ // biome-ignore lint/suspicious/noExplicitAny: opaque props of a heterogeneous component bag
+ Component<Record<string, any>>;
+
+export interface ViewRegistration {
+ /** Registry id — the same id a leaf's `view` field names. */
+ readonly id: string;
+ /** Human label shown in panel dropdowns. */
+ readonly label: string;
+ /** Svelte 5 component, already props-wired (see `src/app/register-views.ts`). */
+ readonly component: ViewComponent;
+ /** Whether the view may appear in panel dropdowns (default true). */
+ readonly panelEligible?: boolean;
+ /** The plugin that contributed the view (plan 03 requires it for those). */
+ readonly pluginId?: string;
+}
+
+/** A registered view with `panelEligible` normalized (default true). */
+export interface RegisteredView {
+ readonly id: string;
+ readonly label: string;
+ readonly component: ViewComponent;
+ readonly panelEligible: boolean;
+ readonly pluginId: string | undefined;
+}
+
+const byId = new Map<string, RegisteredView>();
+const store = writable<readonly RegisteredView[]>([]);
+
+function publish(): void {
+ store.set([...byId.values()]);
+}
+
+/**
+ * Register a view. A duplicate id throws LOUDLY at dev time — two components
+ * claiming one id is a composition-root bug, never a runtime fallback case.
+ */
+export function registerView(registration: ViewRegistration): void {
+ if (byId.has(registration.id)) {
+ throw new Error(`registerView: duplicate view id "${registration.id}"`);
+ }
+ byId.set(registration.id, {
+ id: registration.id,
+ label: registration.label,
+ component: registration.component,
+ panelEligible: registration.panelEligible ?? true,
+ pluginId: registration.pluginId,
+ });
+ publish();
+}
+
+/** Remove a view. Idempotent (removing an unknown id is a no-op). */
+export function unregisterView(id: string): void {
+ if (byId.delete(id)) publish();
+}
+
+/**
+ * Register a PLUGIN view (plan 03) — the ABI-facing registration: the
+ * `pluginId` attribution is FORCED to the importing plugin (plugins cannot
+ * attribute views to another plugin or to no plugin), which is what makes
+ * `unregisterViewsByPlugin`'s unload sweep exact. Returns the per-view
+ * disposer (the plugin's deactivate contract). A duplicate id throws
+ * LOUDLY — the driver catches it and marks the plugin's FE half failed.
+ */
+export function registerPluginView(
+ registration: Omit<ViewRegistration, "pluginId">,
+ pluginId: string,
+): () => void {
+ registerView({ ...registration, pluginId });
+ return () => unregisterView(registration.id);
+}
+
+/**
+ * Remove every view attributed to one plugin (the plan-03 unload sweep).
+ * Idempotent; the reactive registry re-renders open dropdowns, and tiles
+ * holding removed views fall back to `FallbackTile` (never a crash).
+ */
+export function unregisterViewsByPlugin(pluginId: string): void {
+ let changed = false;
+ for (const [id, view] of byId) {
+ if (view.pluginId === pluginId) {
+ byId.delete(id);
+ changed = true;
+ }
+ }
+ if (changed) publish();
+}
+
+/** Look a view up by id (undefined → the renderer shows its fallback tile). */
+export function getView(id: string): RegisteredView | undefined {
+ return byId.get(id);
+}
+
+/** All registered views, in registration order (snapshot). */
+export function listViews(): readonly RegisteredView[] {
+ return [...byId.values()];
+}
+
+/** The panel-eligible subset (`panelEligible: true`) — panel dropdown options. */
+export function listPanelViews(): readonly RegisteredView[] {
+ return listViews().filter((v) => v.panelEligible);
+}
+
+/**
+ * Reactive registry contents (registration order). Subscribe in components —
+ * e.g. `$viewRegistry` in a panel dropdown — so late registrations (plan 03)
+ * re-render open dropdowns.
+ */
+export const viewRegistry: Readable<readonly RegisteredView[]> = {
+ subscribe: store.subscribe,
+};
diff --git a/src/features/layout/logic/seed.test.ts b/src/features/layout/logic/seed.test.ts
new file mode 100644
index 0000000..dfe166a
--- /dev/null
+++ b/src/features/layout/logic/seed.test.ts
@@ -0,0 +1,99 @@
+import { DEFAULT_LAYOUT, type LayoutTree, type SplitNode } from "@dispatch/layout-contract";
+import { describe, expect, it } from "vitest";
+import { DEFAULT_PANEL_VIEWS, overlaySidePanels, seedLayout } from "./seed";
+import { firstCollapsibleNodeId, firstPanelsNode } from "./tree-query";
+
+function sideViews(tree: LayoutTree): string[] {
+ const root = tree.root as SplitNode;
+ const side = root.children[1]?.node;
+ return side?.kind === "panels" ? side.panels.map((p) => p.view) : [];
+}
+
+describe("seedLayout", () => {
+ it("uses the default panels when nothing is stored", () => {
+ const tree = seedLayout(null, ["tabs", "model", "settings"]);
+ expect(sideViews(tree)).toEqual(DEFAULT_PANEL_VIEWS);
+ });
+
+ it("overlays the stored selection, dropping unknown/removed kinds", () => {
+ const tree = seedLayout(
+ ["extensions", "ghost-view", "model"],
+ ["tabs", "model", "extensions", "settings"],
+ );
+ expect(sideViews(tree)).toEqual(["extensions", "model"]);
+ });
+
+ it("falls back to the defaults when the stored selection is all-unknown or empty", () => {
+ expect(sideViews(seedLayout(["ghost"], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("falls back to the defaults when the stored value is not a string array", () => {
+ expect(sideViews(seedLayout("junk", ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout([42], ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ expect(sideViews(seedLayout({ a: 1 }, ["tabs", "model"]))).toEqual(["tabs", "model"]);
+ });
+
+ it("preserves the default tree's ids and the titlebar-unset invariant", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ expect((tree.root as SplitNode).id).toBe("root");
+ expect(tree.titlebar).toBeUndefined();
+ });
+});
+
+describe("overlaySidePanels", () => {
+ it("replaces only the side panels of the given base tree", () => {
+ const tree = overlaySidePanels(DEFAULT_LAYOUT, ["settings", "tasks"]);
+ expect(sideViews(tree)).toEqual(["settings", "tasks"]);
+ // The rest of the tree is untouched.
+ expect((tree.root as SplitNode).children[0]?.node).toEqual(
+ (DEFAULT_LAYOUT.root as SplitNode).children[0]?.node,
+ );
+ });
+});
+
+describe("tree queries", () => {
+ it("firstPanelsNode finds the side panels with its path", () => {
+ const tree = seedLayout(["model"], ["tabs", "model"]);
+ const found = firstPanelsNode(tree);
+ expect(found?.path).toEqual([1]);
+ expect(found?.node.panels.map((p) => p.view)).toEqual(["model"]);
+ });
+
+ it("firstCollapsibleNodeId returns the default tree's side node id", () => {
+ expect(firstCollapsibleNodeId(DEFAULT_LAYOUT)).toBe("side");
+ });
+
+ it("firstCollapsibleNodeId falls back to a path key when the node has no id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [
+ { size: 1, node: { kind: "leaf", view: "chat" } },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", panels: [{ kind: "leaf", view: "tabs" }] },
+ },
+ ],
+ },
+ };
+ // The collapsible child sits at root path index 1 → key "1".
+ expect(firstCollapsibleNodeId(tree)).toBe("1");
+ });
+
+ it("firstCollapsibleNodeId returns null when nothing is collapsible", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: {
+ kind: "split",
+ dir: "row",
+ children: [{ size: 1, node: { kind: "leaf", view: "chat" } }],
+ },
+ };
+ expect(firstCollapsibleNodeId(tree)).toBeNull();
+ });
+});
diff --git a/src/features/layout/logic/seed.ts b/src/features/layout/logic/seed.ts
new file mode 100644
index 0000000..7cd0ba3
--- /dev/null
+++ b/src/features/layout/logic/seed.ts
@@ -0,0 +1,78 @@
+/**
+ * Boot-time tree seeding: the default layout with today's sidebar selection
+ * overlaid from localStorage. Byte-compatible with today's boot behavior
+ * (`dispatch.sidebar.views` read; unknown/removed kinds dropped — the tree
+ * schema has no "unknown view" panel state, so a stale id is skipped exactly
+ * like a view that no longer exists).
+ */
+
+import {
+ DEFAULT_LAYOUT,
+ findById,
+ type LayoutNode,
+ type LayoutTree,
+ type LeafNode,
+ type PanelsNode,
+ type Path,
+} from "@dispatch/layout-contract";
+
+/**
+ * Today's default sidebar selection (the moved-from-top tab bar + the model
+ * controls) — the fallback when nothing (or nothing valid) is stored.
+ */
+export const DEFAULT_PANEL_VIEWS: readonly string[] = ["tabs", "model"];
+
+/**
+ * Seed a tree from DEFAULT_LAYOUT with `storedViews` (the parsed
+ * `dispatch.sidebar.views` value, or null when absent) overlaid onto the
+ * default tree's panels node.
+ *
+ * - null / non-array / array without any known view → the defaults.
+ * - Known views are kept in the stored order; unknown ids are dropped.
+ */
+export function seedLayout(storedViews: unknown, panelViewIds: readonly string[]): LayoutTree {
+ const known = new Set(panelViewIds);
+ const stored = Array.isArray(storedViews)
+ ? storedViews.filter((v): v is string => typeof v === "string")
+ : [];
+ const panels = stored.filter((v) => known.has(v));
+ const selected = panels.length > 0 ? panels : DEFAULT_PANEL_VIEWS.filter((v) => known.has(v));
+ return overlaySidePanels(DEFAULT_LAYOUT, selected);
+}
+
+/**
+ * A NEW tree: `base` with its `"side"` panels node replaced by leaves mounting
+ * `views` (in order). Targets the panels node by id (falls back to no-op when
+ * the base has none); the rest of the tree is shared, not copied.
+ */
+export function overlaySidePanels(base: LayoutTree, views: readonly string[]): LayoutTree {
+ const found = findById(base, "side");
+ if (found === null || found.node.kind !== "panels") return base;
+ const panels: readonly LeafNode[] = views.map((view) => ({ kind: "leaf", view }));
+ const next: PanelsNode = { ...found.node, panels };
+ return { version: 1, root: replaceChild(base.root, found.path, next) };
+}
+
+/** Rebuild the node at `path` (immutably); the replacement must be a panels node. */
+function replaceChild(node: LayoutNode, path: Path, replacement: PanelsNode): LayoutNode {
+ if (path.length === 0) return replacement;
+ const [head, ...rest] = path;
+ if (typeof head !== "number") return node;
+ if (node.kind === "split") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ if (node.kind === "stack") {
+ return {
+ ...node,
+ children: node.children.map((child, i) =>
+ i === head ? { ...child, node: replaceChild(child.node, rest, replacement) } : child,
+ ),
+ };
+ }
+ return node;
+}
diff --git a/src/features/layout/logic/tree-query.ts b/src/features/layout/logic/tree-query.ts
new file mode 100644
index 0000000..aa8dfe9
--- /dev/null
+++ b/src/features/layout/logic/tree-query.ts
@@ -0,0 +1,60 @@
+/**
+ * Tree queries the chrome needs — pure walks over the layout tree.
+ */
+
+import type { LayoutNode, LayoutTree, PanelsNode, Path } from "@dispatch/layout-contract";
+
+/** The first panels node in the tree (the panel-selection owner), if any. */
+export function firstPanelsNode(tree: LayoutTree): { node: PanelsNode; path: Path } | null {
+ const seen: { node: PanelsNode; path: Path }[] = [];
+ const visit = (node: LayoutNode, path: Path): void => {
+ if (seen.length > 0) return;
+ if (node.kind === "panels") {
+ seen.push({ node, path });
+ return;
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) visit(child, [...path, i]);
+ };
+ visit(tree.root, []);
+ return seen[0] ?? null;
+}
+
+/**
+ * The node id of the tree's FIRST collapsible split child — the one the chrome
+ * top bar's toggle reads/writes (`findById("side")` on the default tree;
+ * generically the first collapsible child, root-first then titlebar). Falls
+ * back to a path-derived key when the child's node has no id. Null → the tree
+ * has nothing collapsible (the toggle button hides).
+ */
+export function firstCollapsibleNodeId(tree: LayoutTree): string | null {
+ const visit = (node: LayoutNode, path: Path): string | null => {
+ if (node.kind === "split") {
+ for (const [i, child] of node.children.entries()) {
+ if (!child.collapsible) continue;
+ if (child.node.id !== undefined) return child.node.id;
+ return [...path, i].map((s) => String(s)).join(":");
+ }
+ }
+ const kids: readonly LayoutNode[] =
+ node.kind === "split"
+ ? node.children.map((c) => c.node)
+ : node.kind === "stack"
+ ? node.children.map((c) => c.node)
+ : [];
+ for (const [i, child] of kids.entries()) {
+ const found = visit(child, [...path, i]);
+ if (found !== null) return found;
+ }
+ return null;
+ };
+ const inRoot = visit(tree.root, []);
+ if (inRoot !== null) return inRoot;
+ if (tree.titlebar !== undefined) return visit(tree.titlebar, ["titlebar"]);
+ return null;
+}
diff --git a/src/features/layout/store.svelte.ts b/src/features/layout/store.svelte.ts
new file mode 100644
index 0000000..29e2fa1
--- /dev/null
+++ b/src/features/layout/store.svelte.ts
@@ -0,0 +1,316 @@
+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, every
+ * `extensions-changed`, and every change to the ACTIVE workspace's
+ * per-workspace plugin state (a layout plugin enabled/disabled in the
+ * workspace plugins modal 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;
+ /**
+ * Reset the workspace's layout to the default seed (plan 04 §4.4 — the
+ * plugins modal's "Reset layout to default" affordance:
+ * `DELETE /workspaces/:id/layout` → re-seed → broadcast). Resolves to
+ * `null` on success (the fresh seed is applied locally + pushed via WS to
+ * every client) or to the FAILURE REASON — the no-provider 404 resolves
+ * to the user-facing "no layout plugin enabled" (the button's gate renders
+ * the same reason before this runs).
+ */
+ resetToDefault(workspaceId: string): Promise<string | null>;
+ /** Multi-client coherence (§11): re-probe on `extensions-changed`. */
+ onExtensionsChanged(): void;
+ /**
+ * The active workspace's PER-WORKSPACE PLUGIN STATE changed (the workspace
+ * plugins modal toggled a layout plugin's enablement — the per-workspace
+ * activation rule): re-probe so the newly-enabled provider's tree (or the
+ * no-provider degrade) lands without a reload. A NON-active workspace's
+ * toggle is ignored — its tree is fetched on its next probe (workspace
+ * switch); there is nothing to re-render here.
+ */
+ onWorkspacePluginsChanged(workspaceId: string): 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);
+ },
+
+ async resetToDefault(workspaceId): Promise<string | null> {
+ const result = await http.deleteTree(workspaceId);
+ if (result.ok) {
+ // The server re-seeded and broadcast (every client resets); this
+ // client applies the fresh seed immediately too (idempotent with the
+ // WS echo). Only the ACTIVE workspace's tree is ours to update.
+ notePresent();
+ if (workspaceId === activeWorkspaceId) applyServerTree(result.tree);
+ return null;
+ }
+ // The no-provider 404: the user-facing reason the plan specifies.
+ if (result.notFound) return "no layout plugin enabled";
+ return result.error;
+ },
+
+ 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.
+ });
+ },
+
+ onWorkspacePluginsChanged(workspaceId) {
+ // The per-workspace activation rule: only the ACTIVE workspace's tree
+ // can change what is rendered here — another workspace's toggle is
+ // picked up by its own probe (boot/workspace-switch/§2.6 triggers).
+ if (workspaceId !== activeWorkspaceId) return;
+ void runProbe(activeWorkspaceId, false).catch(() => {
+ // Same quiet-degrade guard as onExtensionsChanged.
+ });
+ },
+ };
+}
diff --git a/src/features/layout/store.test.ts b/src/features/layout/store.test.ts
new file mode 100644
index 0000000..331af95
--- /dev/null
+++ b/src/features/layout/store.test.ts
@@ -0,0 +1,529 @@
+import {
+ type ApplyOpsResult,
+ DEFAULT_LAYOUT,
+ type LayoutOp,
+ type LayoutTree,
+} from "@dispatch/layout-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { LayoutHttp, LayoutHttpResult } from "./adapter/http";
+import { overlaySidePanels } from "./logic/seed";
+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 }>;
+ deleteCalls: string[];
+ getResult: (workspaceId: string) => LayoutHttpResult;
+ putResult: (workspaceId: string, tree: LayoutTree) => LayoutHttpResult;
+ deleteResult: (workspaceId: string) => LayoutHttpResult;
+ opsResult: (workspaceId: string, ops: readonly LayoutOp[]) => ApplyOpsResult;
+}
+
+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 deleteCalls: string[] = [];
+ const fake: HttpFake = {
+ getCalls,
+ putCalls,
+ deleteCalls,
+ getResult,
+ putResult,
+ // Re-scriptable outcome (default: the fresh seed a reset returns).
+ deleteResult: () => ({ ok: true, tree: DEFAULT_LAYOUT }),
+ // Re-scriptable outcome (default: the typed no-provider failure the ABI
+ // documents; tests re-assign mid-test like putResult).
+ opsResult: () => ({ ok: false, error: "no layout provider active" }),
+ // 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(workspaceId) {
+ deleteCalls.push(workspaceId);
+ return fake.deleteResult(workspaceId);
+ },
+ async postOps(workspaceId, ops) {
+ return fake.opsResult(workspaceId, ops);
+ },
+ };
+ 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("a per-workspace plugin-state change on the ACTIVE workspace re-probes (the plugins-modal toggle)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { 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 in THIS workspace
+ store.onWorkspacePluginsChanged("ws-a");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(store.providerState).toBe("present");
+ expect(store.tree).toEqual(served);
+ });
+
+ it("a per-workspace plugin-state change on ANOTHER workspace is ignored (its probe fetches on switch)", async () => {
+ let absent = true;
+ const served = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp(() =>
+ absent
+ ? { ok: false, notFound: true, error: "No layout plugin is enabled in this workspace" }
+ : { ok: true, tree: served },
+ );
+ const store = makeStore(http);
+ await store.probe("ws-a");
+ expect(store.providerState).toBe("absent");
+
+ absent = false; // a DIFFERENT workspace's toggle — nothing re-renders here
+ store.onWorkspacePluginsChanged("ws-other");
+ await vi.advanceTimersByTimeAsync(0);
+ expect(http.getCalls).toEqual(["ws-a"]); // no second probe
+ expect(store.providerState).toBe("absent");
+ });
+
+ 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"]) },
+ ]);
+ });
+});
+
+describe("layout store — resetToDefault (plan 04 §4.4, the reset affordance)", () => {
+ it("DELETEs the workspace's layout, applies the fresh seed, and resolves null", async () => {
+ const custom = overlaySidePanels(DEFAULT_LAYOUT, ["extensions"]);
+ const http = fakeHttp();
+ // The DELETE returns the FRESH SEED (the server re-seeded).
+ http.deleteResult = () => ({ ok: true, tree: DEFAULT_LAYOUT });
+ const store = makeStore(http);
+
+ // Get to a NON-default tree first: a present provider + a pushed edit.
+ http.getResult = () => ({ ok: true, tree: custom });
+ await store.probe("ws-a");
+ expect(store.tree).toEqual(custom);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBeNull();
+ expect(http.deleteCalls).toEqual(["ws-a"]);
+ // The fresh seed is applied locally (the WS broadcast echo is idempotent).
+ expect(store.tree).toEqual(DEFAULT_LAYOUT);
+ expect(store.providerState).toBe("present");
+ });
+
+ it("a 404 (no provider) resolves to the user-facing 'no layout plugin enabled' reason", async () => {
+ const http = fakeHttp();
+ http.deleteResult = () => ({
+ ok: false,
+ notFound: true,
+ error: "No layout provider is active",
+ });
+ const store = makeStore(http);
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("no layout plugin enabled");
+ expect(store.tree).toEqual(DEFAULT_LAYOUT); // untouched local default
+ });
+
+ it("another failure (5xx on a present provider) resolves to its error message", async () => {
+ const http = fakeHttp(() => ({ ok: true, tree: DEFAULT_LAYOUT }));
+ http.deleteResult = () => ({ ok: false, notFound: false, error: "storage write failed" });
+ const store = makeStore(http);
+ await store.probe("ws-a");
+
+ const failure = await store.resetToDefault("ws-a");
+ expect(failure).toBe("storage write failed");
+ });
+});
diff --git a/src/features/layout/ui/FallbackTile.svelte b/src/features/layout/ui/FallbackTile.svelte
new file mode 100644
index 0000000..8fafa7f
--- /dev/null
+++ b/src/features/layout/ui/FallbackTile.svelte
@@ -0,0 +1,13 @@
+<script lang="ts">
+ /**
+ * Shown when a leaf names a view this client has not registered — the tree
+ * is data and can arrive from a backend (plan 02) that knows views this
+ * build lacks. A titled placeholder, never a crash.
+ */
+ let { viewId }: { viewId: string } = $props();
+</script>
+
+<div class="flex flex-col gap-1 rounded-lg bg-base-200 p-3" data-testid="fallback-tile">
+ <h3 class="text-xs font-semibold uppercase opacity-60">{viewId}</h3>
+ <p class="text-sm opacity-60">View not registered.</p>
+</div>
diff --git a/src/features/layout/ui/TileLeaf.svelte b/src/features/layout/ui/TileLeaf.svelte
new file mode 100644
index 0000000..0fd6430
--- /dev/null
+++ b/src/features/layout/ui/TileLeaf.svelte
@@ -0,0 +1,44 @@
+<script lang="ts">
+ /**
+ * Mounts a registered view for a leaf node. Resolution goes through the
+ * REACTIVE registry, so a late registration (plan 03) swaps the tile in
+ * without a tree change. An unknown view id renders a fallback tile —
+ * NEVER a crash (backend-owned trees can name views this client lacks).
+ *
+ * A diagnostic `marker` on the leaf draws an outline around it — the only
+ * node kind without a natural tile wrapper, so the wrapper exists only
+ * when marked (unmarked leaves render exactly as before).
+ */
+ import type { TileMarker } from "@dispatch/layout-contract";
+ import { viewRegistry, type RegisteredView } from "../logic/registry";
+ import { MARKER_OUTLINE_OFFSET, markerOutline } from "../logic/marker";
+ import FallbackTile from "./FallbackTile.svelte";
+
+ let {
+ node,
+ ports,
+ }: {
+ node: { readonly view: string; readonly marker?: TileMarker };
+ ports?: unknown;
+ } = $props();
+
+ const entry: RegisteredView | undefined = $derived(
+ $viewRegistry.find((v) => v.id === node.view),
+ );
+ const View = $derived(entry?.component);
+ const outline = $derived(markerOutline(node.marker));
+</script>
+
+{#snippet content()}
+ {#if View}
+ <View {ports} />
+ {:else}
+ <FallbackTile viewId={node.view} />
+ {/if}
+{/snippet}
+
+{#if outline !== undefined}
+ <div style:outline={outline} style:outline-offset={MARKER_OUTLINE_OFFSET}>{@render content()}</div>
+{:else}
+ {@render content()}
+{/if}
diff --git a/src/features/layout/ui/TileNode.svelte b/src/features/layout/ui/TileNode.svelte
new file mode 100644
index 0000000..5ed8a89
--- /dev/null
+++ b/src/features/layout/ui/TileNode.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ /**
+ * The recursive tile dispatcher: renders the node for its `kind` and recurses
+ * through the container components. Leaf nodes resolve through the view
+ * registry (TileLeaf).
+ */
+ import type { LayoutNode, LayoutOp, Path } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileLeaf from "./TileLeaf.svelte";
+ import TilePanels from "./TilePanels.svelte";
+ import TileSplit from "./TileSplit.svelte";
+ import TileStack from "./TileStack.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: LayoutNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+{#if node.kind === "split"}
+ <TileSplit {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "stack"}
+ <TileStack {node} {path} {ports} {collapse} {onEdit} />
+{:else if node.kind === "panels"}
+ <TilePanels {node} {path} {ports} {onEdit} />
+{:else if node.kind === "leaf"}
+ <TileLeaf {node} {ports} />
+{/if}
diff --git a/src/features/layout/ui/TilePanels.svelte b/src/features/layout/ui/TilePanels.svelte
new file mode 100644
index 0000000..cc5fa89
--- /dev/null
+++ b/src/features/layout/ui/TilePanels.svelte
@@ -0,0 +1,185 @@
+<script lang="ts">
+ /**
+ * A scrolling stack of swappable panels — the generalized ViewSidebar. The
+ * panel list comes from the tree node; dropdown options from the (reactive)
+ * registry's panel-eligible views. Edits emit tree ops upward via `onEdit`;
+ * the OWNER (App today, the backend store in plan 02) applies them and
+ * decides persistence.
+ */
+ import type { LayoutOp, PanelsNode, Path } from "@dispatch/layout-contract";
+ import PlusIcon from "phosphor-svelte/lib/PlusIcon";
+ import { untrack } from "svelte";
+ import { viewRegistry } from "../logic/registry";
+ import { addPanelOp, firstPanelView, removePanelOp, swapPanelOp } from "../logic/panels-edit";
+ import {
+ initialPanelKeys,
+ type PanelKeys,
+ reconcilePanelKeys,
+ swappedPanels,
+ } from "../logic/panel-keys";
+ import { markerStyle } from "../logic/marker";
+ import { morphPanelHeight, panelGrowIn, panelShrinkOut } from "./panel-motion";
+ import TileLeaf from "./TileLeaf.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ onEdit,
+ renderMarker = false,
+ }: {
+ node: PanelsNode;
+ path: Path;
+ ports?: unknown;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ /**
+ * Whether to draw the node's diagnostic marker on this root. Default NO:
+ * a panels node is the scrolled CONTENT of a split's collapsible child,
+ * so an outline here wraps the full scrollable region and scrolls with
+ * it — the split draws the marker on the aside (the fixed viewport)
+ * instead. Pass `true` only for a panels node used outside a split.
+ */
+ renderMarker?: boolean;
+ } = $props();
+
+ // Panel-eligible views only (decision 5): chat/composer/message-queue never
+ // appear in the dropdowns. Reactive so late registrations re-render options.
+ const panelViews = $derived($viewRegistry.filter((v) => v.panelEligible));
+ const addView = $derived(firstPanelView(panelViews));
+
+ // Stable per-panel keys (pure reconcile, memoised across tree updates) so a
+ // closed panel is the one that animates out, not whichever is last.
+ // A local ✕ click hints WHICH panel left (consumed by the next tree change;
+ // ignored if that change isn't exactly this removal, e.g. a remote edit).
+ let keyMemo: PanelKeys | null = null;
+ let removeHint: number | undefined;
+ const keyed = $derived.by(() => {
+ const views = node.panels.map((p) => p.view);
+ keyMemo =
+ keyMemo === null ? initialPanelKeys(views) : reconcilePanelKeys(keyMemo, views, removeHint);
+ removeHint = undefined;
+ return keyMemo;
+ });
+
+ function removePanel(idx: number): void {
+ removeHint = idx;
+ onEdit([removePanelOp(path, idx)]);
+ }
+
+ // View SWAP: the panel BOX (the well) animates from the old view's height
+ // to the new one's. Measure before the DOM updates, animate after. (Adds/removes are
+ // the per-panel in:/out: transitions below.)
+ let columnEl = $state<HTMLDivElement | undefined>();
+ /** The keys the DOM currently shows (set after each update). */
+ let shownKeys: PanelKeys | null = null;
+ let before: { keys: PanelKeys; heights: Map<number, number> } | null = null;
+ $effect.pre(() => {
+ const next = keyed;
+ untrack(() => {
+ const heights = new Map<number, number>();
+ for (const el of panelEls()) {
+ const well = wellOf(el);
+ if (well !== null) heights.set(Number(el.dataset.panelKey), well.offsetHeight);
+ }
+ before = { keys: shownKeys ?? next, heights };
+ });
+ });
+ $effect(() => {
+ const now = keyed;
+ untrack(() => {
+ const prev = before;
+ before = null;
+ shownKeys = now;
+ if (prev === null) return;
+ for (const key of swappedPanels(prev.keys, now).keys()) {
+ const el = panelEls().find((e) => Number(e.dataset.panelKey) === key);
+ const well = el === undefined ? null : wellOf(el);
+ const from = prev.heights.get(key);
+ if (well !== null && from !== undefined) morphPanelHeight(well, from);
+ }
+ });
+ });
+
+ function wellOf(panel: HTMLElement): HTMLElement | null {
+ return panel.querySelector<HTMLElement>(":scope > [data-panel-well]");
+ }
+
+ function panelEls(): HTMLElement[] {
+ return columnEl === undefined
+ ? []
+ : [...columnEl.querySelectorAll<HTMLElement>(":scope > [data-panel-key]")];
+ }
+</script>
+
+<!-- `shrink-0`: this column is the sidebar's SCROLLED content, so it keeps its
+ natural height — if it shrank to the scroller (min-h-0 in a flex column),
+ its children overflowed it and the trailing margin was never scrollable. -->
+<!-- No flex `gap`: each panel's wrapper carries the gap as `pb-2` instead, so
+ it grows/shrinks WITH the panel's height animation (a flex gap would jump). -->
+<div
+ bind:this={columnEl}
+ class="flex min-h-0 shrink-0 flex-col"
+ style={renderMarker ? markerStyle(node.marker) : undefined}
+ data-panels
+>
+ {#each node.panels as panel, idx (keyed.keys[idx])}
+ <!-- Add/close animate the WRAPPER's height (it carries the inter-panel gap,
+ so panels below slide smoothly); a view swap animates the WELL's
+ height. Either way only a height is tweened (clipped) — the contents
+ keep their natural size. `.inset-well` (app.css): recessed into the
+ sidebar, like the chat's thinking/tool blocks. -->
+ <div
+ class="shrink-0 pb-2"
+ data-panel-key={keyed.keys[idx]}
+ in:panelGrowIn
+ out:panelShrinkOut
+ >
+ <div class="inset-well flex flex-col p-3" data-panel-well>
+ <div class="flex items-center gap-1">
+ <select
+ class="select select-bordered select-sm flex-1"
+ aria-label="Select a view"
+ value={panel.view}
+ onchange={(e) => onEdit([swapPanelOp(path, idx, e.currentTarget.value)])}
+ >
+ <option value="" disabled>Select a view</option>
+ {#each panelViews as view (view.id)}
+ <option value={view.id}>{view.label}</option>
+ {/each}
+ </select>
+ {#if idx > 0}
+ <button
+ type="button"
+ class="btn btn-square btn-ghost btn-sm shrink-0"
+ aria-label="Remove view"
+ onclick={() => removePanel(idx)}
+ >
+ ✕
+ </button>
+ {/if}
+ </div>
+
+ <div class="mt-2">
+ <TileLeaf node={panel} {ports} />
+ </div>
+ </div>
+ </div>
+ {/each}
+
+ {#if addView !== null}
+ <!-- `.raised-btn` (app.css): the mirror of the view wells above it —
+ pops out where they sink in; pressed, it sinks in like them. Height =
+ DaisyUI's default button size (`--size-field` × 10), as the `btn` it
+ replaced; `shrink-0` so the column can never squash it. The last panel's
+ `pb-2` is the gap above it; `mb-2` = the same gap below, so it
+ doesn't sit tight to the end. -->
+ <button
+ type="button"
+ class="raised-btn mb-2 h-[calc(var(--size-field,0.25rem)*10)] w-full shrink-0"
+ aria-label="Add view"
+ onclick={() => addView !== null && onEdit([addPanelOp(path, addView)])}
+ >
+ <PlusIcon size={18} weight="bold" aria-hidden="true" />
+ </button>
+ {/if}
+</div>
diff --git a/src/features/layout/ui/TileRoot.svelte b/src/features/layout/ui/TileRoot.svelte
new file mode 100644
index 0000000..82ad430
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ /**
+ * The tile-tree root. Renders the tree (titlebar slot first — unset renders
+ * nothing, D-L5), applies panel edit ops to its `tree` prop, and forwards the
+ * new tree via `onTreeChange`. The OWNER decides what persistence means
+ * (App/localStorage today; the backend layout store in plan 02).
+ */
+ import { applyOps, type LayoutOp, type LayoutTree } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ tree,
+ onTreeChange,
+ ports,
+ collapse,
+ }: {
+ tree: LayoutTree;
+ onTreeChange?: (tree: LayoutTree) => void;
+ ports?: unknown;
+ collapse: CollapseStore;
+ } = $props();
+
+ function handleEdit(ops: readonly LayoutOp[]): void {
+ const result = applyOps(tree, ops);
+ if (result.ok) {
+ onTreeChange?.(result.tree);
+ } else {
+ // A panel edit that fails validation (e.g. removing the last panel) is
+ // refused loudly in dev — the tree keeps its previous state.
+ console.error(`TileRoot: layout edit refused: ${result.error}`);
+ }
+ }
+</script>
+
+{#if tree.titlebar !== undefined}
+ <TileNode node={tree.titlebar} path={["titlebar"]} {ports} {collapse} onEdit={handleEdit} />
+{/if}
+<TileNode node={tree.root} path={[]} {ports} {collapse} onEdit={handleEdit} />
diff --git a/src/features/layout/ui/TileRoot.test.ts b/src/features/layout/ui/TileRoot.test.ts
new file mode 100644
index 0000000..4b404a1
--- /dev/null
+++ b/src/features/layout/ui/TileRoot.test.ts
@@ -0,0 +1,394 @@
+import type { LayoutTree, SplitNode } from "@dispatch/layout-contract";
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { gsap } from "gsap";
+import { describe, expect, it, vi } from "vitest";
+import { createCollapseStore, registerView, unregisterView } from "../index";
+import FixtureA from "./fixtures/FixtureA.svelte";
+import FixtureB from "./fixtures/FixtureB.svelte";
+import FixtureC from "./fixtures/FixtureC.svelte";
+import TileRoot from "./TileRoot.svelte";
+
+// Fixture registry: two panel-eligible views + one content-only view (a THIRD
+// component, so every fixture testid is unique in the rendered tree).
+registerView({ id: "fix-a", label: "Alpha View", component: FixtureA });
+registerView({ id: "fix-b", label: "Beta View", component: FixtureB });
+registerView({ id: "fix-content", label: "Content", component: FixtureC, panelEligible: false });
+
+function leaf(view: string): { kind: "leaf"; view: string } {
+ return { kind: "leaf", view };
+}
+
+/** split[ stack[leaf a (grow), leaf content], collapsible panels[leaf b] ] */
+function defaultTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }, { node: leaf("fix-content") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+/** A bare panels root — panel EDITS without the collapsible chrome in the way. */
+function panelsOnlyTree(...views: string[]): LayoutTree {
+ return { version: 1, root: { kind: "panels", id: "side", panels: views.map(leaf) } };
+}
+
+/** The default test tree WITH the diagnostic markers on its two root tiles. */
+function markedTestTree(): LayoutTree {
+ return {
+ version: 1,
+ root: {
+ kind: "split",
+ id: "root",
+ dir: "row",
+ children: [
+ {
+ size: 1,
+ node: {
+ kind: "stack",
+ id: "main",
+ marker: "main",
+ children: [{ grow: 1, node: leaf("fix-a") }],
+ },
+ },
+ {
+ size: 0,
+ basis: 320,
+ collapsible: true,
+ node: { kind: "panels", id: "side", marker: "side", panels: [leaf("fix-b")] },
+ },
+ ],
+ },
+ };
+}
+
+function sidePanelsOf(tree: LayoutTree | null): string[] {
+ if (tree === null) return [];
+ const side = (tree.root as SplitNode).children?.[1]?.node;
+ return side?.kind === "panels"
+ ? side.panels.map((p) => p.view)
+ : tree.root.kind === "panels"
+ ? tree.root.panels.map((p) => p.view)
+ : [];
+}
+
+describe("TileRoot", () => {
+ it("renders every node kind: split, stack, panels, leaf", () => {
+ // A FRESH collapse store per test — the store is stateful chrome.
+ render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ // Stack children mount their registered components.
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-c")).toBeInTheDocument();
+ // The panels node renders its panel leaf + the dropdown.
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ expect(screen.getByRole("combobox", { name: "Select a view" })).toBeInTheDocument();
+ });
+
+ it("shows a fallback tile (never a crash) for an unknown view id", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "not-registered-anywhere" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fallback = screen.getByTestId("fallback-tile");
+ expect(fallback).toHaveTextContent("not-registered-anywhere");
+ expect(fallback).toHaveTextContent("View not registered.");
+ });
+
+ it("renders nothing for the titlebar slot while it is unset (D-L5)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[data-testid='fallback-tile']")).toHaveLength(0);
+ });
+
+ it("a set titlebar renders its node (the slot works; nothing populates it today)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ titlebar: { kind: "leaf", view: "fix-a" },
+ root: { kind: "leaf", view: "fix-b" },
+ };
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ expect(screen.getByTestId("fixture-a")).toBeInTheDocument();
+ expect(screen.getByTestId("fixture-b")).toBeInTheDocument();
+ });
+
+ it("the collapsible child reflects the collapse store and toggles through it", async () => {
+ const collapse = createCollapseStore({ viewportWidth: 1024 });
+ const { container } = render(TileRoot, { props: { tree: defaultTestTree(), collapse } });
+ const aside = container.querySelector<HTMLElement>("[data-testid='collapsible-child']");
+ expect(aside).not.toBeNull();
+ // Wide viewport default: open → full basis width.
+ expect(aside).toHaveAttribute("data-open", "true");
+ expect(aside?.style.width).toBe("320px");
+
+ await collapse.toggle("side");
+ expect(aside).toHaveAttribute("data-open", "false");
+ expect(aside?.style.width).toBe("0px");
+ });
+
+ it("a dropdown swap emits replaceView and the owner receives the changed tree", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Select a view" }), "fix-a");
+ expect(sidePanelsOf(changed)).toEqual(["fix-a"]);
+ // TileRoot is a CONTROLLED component: applying the op is the owner's job
+ // (App re-renders with the new tree; the test doesn't feed it back).
+ });
+
+ it("the + button emits insertPanel pre-selecting the first eligible view", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ await user.click(screen.getByRole("button", { name: "Add view" }));
+ expect(sidePanelsOf(changed)).toEqual(["fix-b", "fix-a"]);
+ });
+
+ it("the ✕ button emits removeNode for that panel (absent on the first panel)", async () => {
+ const user = userEvent.setup();
+ let changed: LayoutTree | null = null;
+ render(TileRoot, {
+ props: {
+ tree: panelsOnlyTree("fix-b", "fix-a"),
+ collapse: createCollapseStore(),
+ onTreeChange: (t) => (changed = t),
+ },
+ });
+
+ // Only panels after the first can be removed — like ViewSidebar today.
+ const removeButtons = screen.getAllByRole("button", { name: "Remove view" });
+ expect(removeButtons).toHaveLength(1);
+ await user.click(removeButtons[0] as HTMLElement);
+ expect(sidePanelsOf(changed)).toEqual(["fix-b"]);
+ });
+
+ it("an invalid edit is refused: the first panel has no remove button", () => {
+ const tree = panelsOnlyTree("fix-b");
+ let changed: LayoutTree | null = null;
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, {
+ props: { tree, collapse: createCollapseStore(), onTreeChange: (t) => (changed = t) },
+ });
+ expect(screen.queryByRole("button", { name: "Remove view" })).toBeNull();
+ expect(changed).toBeNull();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it("a stack child with grow renders in a flex-grown wrapper; others natural", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const grown = container.querySelector("[data-testid='fixture-a']")?.closest(".min-h-0");
+ expect(grown?.getAttribute("style")).toContain("flex: 1 1 0%");
+ const natural = container.querySelector("[data-testid='fixture-c']")?.closest(".min-h-0");
+ expect(natural?.getAttribute("style")).toContain("flex: 0 0 auto");
+ });
+
+ it("the stack column is full-height so a grow child fills it even when empty", () => {
+ // jsdom can't compute flex layout — the honest seam is the CSS class.
+ // WITHOUT `h-full` the stack column's height is content-based, so the
+ // grow child's `flex: 1 1 0%` has no space to fill: an EMPTY chat leaf
+ // collapses to ~0 and the composer below it rides to the top (instead of
+ // staying pinned at the bottom of the column).
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ const stackColumn = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stackColumn).toHaveClass("h-full");
+ });
+
+ it("a marked stack/panels node draws its diagnostic outline (main red, side green)", () => {
+ // The inline outline IS the honest seam: marker values arrive in runtime
+ // tree data (Tailwind can't see them — dynamic classes would silently
+ // render nothing), so the renderer applies an inline style, and the test
+ // asserts exactly what lands on the tile wrapper.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline: 4px solid red");
+ // The SIDE marker frames the SCROLL CONTAINER — the sidebar's fixed,
+ // opaquely-painted viewport (the aside's bg is transparent; this div's
+ // bg-base-100 would cover an outline drawn on the aside). The panels
+ // root is its SCROLLED content, so an outline there would wrap the
+ // whole scrollable region and scroll along with it.
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("outlines are INSET so they draw inside the tile's box (never clipped at screen edges)", () => {
+ // Tiles span their containers edge-to-edge, so a default (outside)
+ // outline overflows the visible area and is clipped away — the offset
+ // pulls the ring inside the box. Pin it on both marked tiles.
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const stack = container.querySelector("[data-testid='fixture-a']")?.closest(".flex-col");
+ expect(stack?.getAttribute("style")).toContain("outline-offset: -4px");
+ const viewport = container.querySelector("[data-testid='sidebar-scroll-viewport']");
+ expect(viewport?.getAttribute("style")).toContain("outline-offset: -4px");
+ });
+
+ it("the panels CONTENT carries no outline (it scrolls — the marker must not scroll with it)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: markedTestTree(), collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ const panels = container.querySelector("[data-testid='fixture-b']")?.closest("[data-panels]");
+ expect(panels?.getAttribute("style") ?? "").not.toContain("outline");
+ });
+
+ it("an unmarked tree draws NO outlines (trees without the field render exactly as before)", () => {
+ const { container } = render(TileRoot, {
+ props: { tree: defaultTestTree(), collapse: createCollapseStore() },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("an unknown marker value draws nothing (the renderer ignores what it doesn't know)", () => {
+ // Both root tiles marked with a value the renderer doesn't know.
+ const tree = markedTestTree();
+ const root = tree.root;
+ if (root.kind !== "split") return;
+ const junked = root.children.map((child) => ({
+ ...child,
+ node: { ...child.node, marker: "purple" } as unknown as typeof child.node,
+ }));
+ const marked: LayoutTree = { version: 1, root: { ...root, children: junked } };
+ const { container } = render(TileRoot, {
+ props: { tree: marked, collapse: createCollapseStore({ viewportWidth: 1024 }) },
+ });
+ expect(container.querySelectorAll("[style*='outline']")).toHaveLength(0);
+ });
+
+ it("a marked LEAF draws its outline around a wrapper (the only wrapperless node kind)", () => {
+ const tree: LayoutTree = {
+ version: 1,
+ root: { kind: "leaf", view: "fix-a", marker: "side" },
+ };
+ const { container } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const fixture = container.querySelector("[data-testid='fixture-a']");
+ expect(fixture?.parentElement?.getAttribute("style")).toContain("outline: 4px solid green");
+ });
+
+ it("the dropdown lists panel-eligible views only (decision 5)", () => {
+ render(TileRoot, { props: { tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() } });
+ const select = screen.getByRole("combobox", { name: "Select a view" });
+ const labels = [...select.querySelectorAll("option")].map((o) => o.textContent);
+ expect(labels).toContain("Alpha View");
+ expect(labels).toContain("Beta View");
+ expect(labels).not.toContain("Content");
+ });
+
+ it("an unregistered view id inside a panel renders the fallback (not a crash)", () => {
+ render(TileRoot, {
+ props: { tree: panelsOnlyTree("ghost-view"), collapse: createCollapseStore() },
+ });
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ });
+});
+
+describe("TileRoot registry cleanup", () => {
+ it("unregisters the fixture views (the registry is module-global per test file)", () => {
+ unregisterView("fix-a");
+ unregisterView("fix-b");
+ unregisterView("fix-content");
+ const tree: LayoutTree = { version: 1, root: { kind: "leaf", view: "fix-a" } };
+ const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
+ try {
+ render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ // Unregistered → the fallback tile, not a crash.
+ expect(screen.getByTestId("fallback-tile")).toBeInTheDocument();
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+});
+
+describe("TilePanels — panel identity + height animation", () => {
+ const wrappers = (): HTMLElement[] => [
+ ...document.querySelectorAll<HTMLElement>("[data-panels] > [data-panel-key]"),
+ ];
+
+ it("closing the MIDDLE panel removes that panel's DOM, keeping the others' nodes", async () => {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a", "fix-b"), collapse: createCollapseStore() },
+ });
+ const [first, , third] = wrappers();
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-b"), collapse: createCollapseStore() });
+ // Identity, not position: the third panel's node survived (index keying
+ // would have reused the middle node and dropped the last one).
+ expect(wrappers()).toEqual([first, third]);
+ expect(screen.queryByTestId("fixture-a")).toBeNull();
+ });
+
+ it("✕ on one of two IDENTICAL views removes exactly that panel", async () => {
+ const user = userEvent.setup();
+ const tree = panelsOnlyTree("fix-b", "fix-a", "fix-a");
+ const { rerender } = render(TileRoot, { props: { tree, collapse: createCollapseStore() } });
+ const [first, , third] = wrappers();
+ // Click the MIDDLE panel's ✕ (the first panel has none), then the owner
+ // applies the edit — the two fix-a panels are indistinguishable by data.
+ const [middleClose] = screen.getAllByRole("button", { name: "Remove view" });
+ await user.click(middleClose as HTMLElement);
+ await rerender({ tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() });
+ expect(wrappers()).toEqual([first, third]);
+ });
+
+ it("with motion allowed, a closed panel stays in flow while it shrinks out", async () => {
+ vi.stubGlobal(
+ "matchMedia",
+ vi.fn(() => ({ matches: false }) as unknown as MediaQueryList),
+ );
+ try {
+ const { rerender } = render(TileRoot, {
+ props: { tree: panelsOnlyTree("fix-b", "fix-a"), collapse: createCollapseStore() },
+ });
+ const closing = wrappers()[1];
+ await rerender({ tree: panelsOnlyTree("fix-b"), collapse: createCollapseStore() });
+ // jsdom's stubbed Element.animate never finishes, so the outro holds.
+ expect(closing?.isConnected).toBe(true);
+ expect(gsap.getTweensOf(closing as HTMLElement)).toHaveLength(1);
+ } finally {
+ vi.unstubAllGlobals();
+ }
+ });
+});
diff --git a/src/features/layout/ui/TileSplit.svelte b/src/features/layout/ui/TileSplit.svelte
new file mode 100644
index 0000000..84fa075
--- /dev/null
+++ b/src/features/layout/ui/TileSplit.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ /**
+ * A flex row/column of child subtrees. Child sizing comes from the tree:
+ * `size` → flex-grow (+ optional `basis`), and `collapsible` children get
+ * today's EXACT sidebar behavior — width/translate transition, `lg:relative`
+ * in-flow push vs `max-lg:absolute` overlay with a backdrop. Collapse state
+ * is chrome (per-node-id), owned by the injected collapse store.
+ */
+ import type { LayoutOp, Path, SplitNode, TileMarker } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: SplitNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+
+ const state = $derived($collapse);
+
+ /** Collapse state key: the node's id when present, else its path (stable enough for chrome). */
+ function keyOf(i: number, id: string | undefined): string {
+ return id ?? [...path, i].map((s) => String(s)).join(":");
+ }
+
+ function isOpen(i: number, id: string | undefined): boolean {
+ const key = keyOf(i, id);
+ return state.openByNode[key] ?? state.defaultOpen;
+ }
+
+ /**
+ * The scroll container's inline style: the fixed sidebar width + slide
+ * transform + any diagnostic marker (inset) — none when unmarked.
+ *
+ * The marker goes on THIS div, not the aside: the inner div paints an
+ * OPAQUE full-height background (bg-base-100) that would cover an outline
+ * drawn on the aside. This div's own box never moves — only its contents
+ * scroll — and overflow-y-auto clips descendants, never an element's own
+ * outline, so the marker frames exactly the visible sidebar.
+ */
+ function scrollViewportStyle(
+ basis: number | undefined,
+ open: boolean,
+ marker: TileMarker | undefined,
+ ): string {
+ const parts = [`width: ${basis ?? 320}px`, `transform: translateX(${open ? "0" : "100%"})`];
+ const markerCss = markerStyle(marker);
+ if (markerCss !== undefined) parts.push(markerCss);
+ return parts.join("; ");
+ }
+</script>
+
+{#if node.dir === "col"}
+ <div class="flex min-h-0 w-full flex-col" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+ </div>
+{:else}
+ <div class="flex min-h-0 h-full flex-row" style={markerStyle(node.marker)}>
+ {#each node.children as child, i (i)}
+ {@const key = keyOf(i, child.node.id)}
+ {#if child.collapsible}
+ {@const open = isOpen(i, child.node.id)}
+ <!--
+ A diagnostic marker on a collapsible child is drawn on the SCROLL
+ CONTAINER below — the sidebar's fixed, opaquely-painted viewport —
+ NOT on the child's own root element: that root renders INSIDE the
+ scroll container, so an outline there would wrap the full
+ scrollable content and scroll along with it instead of framing the
+ visible panel.
+ -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <!-- Viewport padding: top/right/bottom always; LEFT only in overlay mode
+ (< lg) — inline (≥ lg) the chat column's gutter already separates the
+ views, but the overlay's left edge is the panel's own edge. -->
+ <aside
+ class="flex shrink-0 flex-col overflow-x-hidden transition-[width] duration-300 ease-out max-lg:absolute max-lg:inset-y-0 max-lg:right-0 max-lg:z-30 lg:relative"
+ style="width: {open ? (child.basis ?? 320) : 0}px"
+ data-testid="collapsible-child"
+ data-open={open}
+ >
+ <div
+ class="flex h-full flex-col gap-2 overflow-y-auto bg-base-100 pt-3 pr-3 pb-3 max-lg:pl-3 transition-transform duration-300 ease-out"
+ style={scrollViewportStyle(child.basis, open, child.node.marker)}
+ data-testid="sidebar-scroll-viewport"
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ </aside>
+ {#if open}
+ <!-- Backdrop: only on narrow screens (overlay mode), click to close. -->
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
+ <div
+ class="fixed inset-0 z-20 bg-black/30 lg:hidden"
+ role="button"
+ tabindex="0"
+ aria-label="Close sidebar"
+ onclick={() => collapse.setOpen(key, false)}
+ onkeydown={(e) => {
+ if (e.key === "Escape" || e.key === "Enter") collapse.setOpen(key, false);
+ }}
+ ></div>
+ {/if}
+ {:else}
+ <div
+ class="min-h-0 min-w-0"
+ style={"flex: " + child.size + " 1 " + (child.basis === undefined ? "0%" : child.basis + "px")}
+ >
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/if}
+ {/each}
+ </div>
+{/if}
diff --git a/src/features/layout/ui/TileStack.svelte b/src/features/layout/ui/TileStack.svelte
new file mode 100644
index 0000000..c8f8cae
--- /dev/null
+++ b/src/features/layout/ui/TileStack.svelte
@@ -0,0 +1,42 @@
+<script lang="ts">
+ /**
+ * A plain vertical container (today's left column). `grow` children flex
+ * (`flex: grow 1 0%` — the chat region scrolls internally, exactly like
+ * today's `flex-1`); children without grow take natural height.
+ *
+ * `h-full` keeps the container's main size DEFINITE (the parent is the
+ * split-child wrapper, cross-stretched to the full-height split row). With
+ * an indefinite height, `flex: 1 1 0%` degenerates to content sizing — an
+ * EMPTY grow child collapses to ~0 and the siblings below it (composer)
+ * ride to the top instead of staying pinned at the bottom.
+ */
+ import type { LayoutOp, Path, StackNode } from "@dispatch/layout-contract";
+ import type { CollapseStore } from "../logic/collapse";
+ import { markerStyle } from "../logic/marker";
+ import TileNode from "./TileNode.svelte";
+
+ let {
+ node,
+ path,
+ ports,
+ collapse,
+ onEdit,
+ }: {
+ node: StackNode;
+ path: Path;
+ ports?: unknown;
+ collapse: CollapseStore;
+ onEdit: (ops: readonly LayoutOp[]) => void;
+ } = $props();
+</script>
+
+<div
+ class="flex h-full min-h-0 min-w-0 flex-col overflow-hidden"
+ style={markerStyle(node.marker)}
+>
+ {#each node.children as child, i (i)}
+ <div class="min-h-0 min-w-0" style={"flex: " + (child.grow ?? 0) + (child.grow === undefined ? " 0 auto" : " 1 0%")}>
+ <TileNode node={child.node} path={[...path, i]} {ports} {collapse} {onEdit} />
+ </div>
+ {/each}
+</div>
diff --git a/src/features/layout/ui/fixtures/FixtureA.svelte b/src/features/layout/ui/fixtures/FixtureA.svelte
new file mode 100644
index 0000000..e55ba2a
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureA.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Alpha" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-a">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureB.svelte b/src/features/layout/ui/fixtures/FixtureB.svelte
new file mode 100644
index 0000000..1c48584
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureB.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Beta" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-b">{label}</div>
diff --git a/src/features/layout/ui/fixtures/FixtureC.svelte b/src/features/layout/ui/fixtures/FixtureC.svelte
new file mode 100644
index 0000000..2bceb67
--- /dev/null
+++ b/src/features/layout/ui/fixtures/FixtureC.svelte
@@ -0,0 +1,6 @@
+<script lang="ts">
+ // A fixture view for renderer tests — ignores any props it receives.
+ let { label = "Gamma" }: { label?: string } = $props();
+</script>
+
+<div data-testid="fixture-c">{label}</div>
diff --git a/src/features/layout/ui/panel-motion.ts b/src/features/layout/ui/panel-motion.ts
new file mode 100644
index 0000000..7fc5edf
--- /dev/null
+++ b/src/features/layout/ui/panel-motion.ts
@@ -0,0 +1,83 @@
+import { gsap } from "gsap";
+import type { TransitionConfig } from "svelte/transition";
+
+/**
+ * GSAP height animations for sidebar panels — the DOM EFFECT half (stable
+ * panel identity lives in `../logic/panel-keys.ts`).
+ *
+ * Every animation changes ONLY the panel wrapper's height (clipped with
+ * `overflow: hidden`); the panel's contents keep their natural size the whole
+ * time — the wrapper reveals / hides them like a drawer, it never scales
+ * them. The wrapper's bottom padding is the gap between panels, so it grows /
+ * shrinks with the panel and the panels below slide smoothly.
+ *
+ * - `panelGrowIn` — Svelte `in:` transition: an added panel grows 0 → auto.
+ * - `panelShrinkOut` — Svelte `out:` transition: a closed panel shrinks to 0
+ * while staying in flow (Svelte keeps the node for the returned duration).
+ * - `morphPanelHeight` — a swapped view: the panel BOX (the well, so its
+ * edges move with it — no empty gap below a shrinking box) animates from
+ * the old view's height to the new one's.
+ *
+ * Svelte's `in:` does not run on first mount, so loading the page never
+ * animates. Disabled when the user prefers reduced motion (or the
+ * environment can't tell, e.g. jsdom) — then every change is instant.
+ */
+
+const DURATION = 0.35;
+const EASE = "power2.inOut";
+
+export function panelMotionAllowed(): boolean {
+ return (
+ typeof window !== "undefined" &&
+ typeof window.matchMedia === "function" &&
+ !window.matchMedia("(prefers-reduced-motion: reduce)").matches
+ );
+}
+
+export function panelGrowIn(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: 0, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+ return { duration: DURATION * 1000 };
+}
+
+export function panelShrinkOut(node: HTMLElement): TransitionConfig {
+ if (!panelMotionAllowed()) return { duration: 0 };
+ // e.g. closed mid-grow / mid-swap: shrink from wherever it is.
+ gsap.killTweensOf([node, ...node.querySelectorAll(":scope > *")]);
+ gsap.to(node, {
+ height: 0,
+ overflow: "hidden",
+ duration: DURATION,
+ ease: EASE,
+ });
+ return { duration: DURATION * 1000 };
+}
+
+/**
+ * Animate `node` from `fromHeight` (its height before the view swapped) to
+ * its new natural height. Call AFTER the DOM shows the new view.
+ */
+export function morphPanelHeight(node: HTMLElement, fromHeight: number): void {
+ if (!panelMotionAllowed()) return;
+ gsap.killTweensOf(node);
+ gsap.fromTo(
+ node,
+ { height: fromHeight, overflow: "hidden" },
+ {
+ height: "auto",
+ duration: DURATION,
+ ease: EASE,
+ clearProps: "height,overflow",
+ },
+ );
+}