summaryrefslogtreecommitdiffhomepage
path: root/src/features/layout/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/layout/logic')
-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/panels-edit.test.ts29
-rw-r--r--src/features/layout/logic/panels-edit.ts33
-rw-r--r--src/features/layout/logic/registry.test.ts80
-rw-r--r--src/features/layout/logic/registry.ts107
-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
9 files changed, 701 insertions, 0 deletions
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/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..443e50d
--- /dev/null
+++ b/src/features/layout/logic/registry.test.ts
@@ -0,0 +1,80 @@
+import type { Component } from "svelte";
+import { afterEach, describe, expect, it } from "vitest";
+import {
+ getView,
+ listPanelViews,
+ listViews,
+ registerView,
+ unregisterView,
+ 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"));
+ });
+});
diff --git a/src/features/layout/logic/registry.ts b/src/features/layout/logic/registry.ts
new file mode 100644
index 0000000..b072d2f
--- /dev/null
+++ b/src/features/layout/logic/registry.ts
@@ -0,0 +1,107 @@
+/**
+ * The view registry — the map from view id → component that tile leaves mount.
+ *
+ * Every view the renderer can place (the 14 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();
+}
+
+/** 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;
+}