diff --git a/web/src/components/layout/MultiView.test.tsx b/web/src/components/layout/MultiView.test.tsx
new file mode 100644
index 0000000..1b49c4c
--- /dev/null
+++ b/web/src/components/layout/MultiView.test.tsx
@@ -0,0 +1,96 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { leaves } from "../../layout.js";
+import { createLayoutStore } from "../../stores/layoutStore.js";
+import type { Session } from "../../types.js";
+import { MultiView } from "./MultiView.js";
+
+vi.mock("../terminal/TerminalPane.js", () => ({
+ TerminalPane: ({ sessionId }: { sessionId: string }) =>
,
+}));
+
+vi.mock("../structured/StructuredPane.js", () => ({
+ StructuredPane: ({ sessionId }: { sessionId: string }) => ,
+}));
+
+afterEach(cleanup);
+
+function store() {
+ return createLayoutStore({
+ load: () => null,
+ save: vi.fn(),
+ scheduler: { setTimer: () => 1, clearTimer: () => {} },
+ });
+}
+
+function session(id: string, over: Partial = {}): Session {
+ return {
+ id,
+ cwd: "/home/n",
+ command: "claude",
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-01-01T00:00:00Z",
+ ...over,
+ } as Session;
+}
+
+describe("MultiView", () => {
+ it("shows the empty state when the active tab has no panes", () => {
+ const s = store();
+ render();
+ expect(screen.getByText(/No session open/)).toBeTruthy();
+ });
+
+ it("renders a pane per leaf, dispatching to the terminal or structured body", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("s2");
+ const sessions = new Map([
+ ["s1", session("s1")],
+ ["s2", session("s2", { view: "Structured" })],
+ ]);
+ render();
+ expect(screen.getByTestId("terminal-s1")).toBeTruthy();
+ expect(screen.getByTestId("structured-s2")).toBeTruthy();
+ });
+
+ it("split right adds a second pane to the tree", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render();
+ fireEvent.click(screen.getByTitle("Split right"));
+ expect(screen.getAllByText("Empty pane")).toHaveLength(1);
+ });
+
+ it("close pane removes it", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ const leafId = leaves(s.getState().layout.tabs[0].root)[0].id;
+ s.getState().splitFocused(leafId, "row"); // adds an empty leaf beside s1
+ render();
+ // splitFocused(after: true) puts the fresh empty leaf second — close it.
+ fireEvent.click(screen.getAllByTitle("Close pane")[1]);
+ expect(screen.queryByText("Empty pane")).toBeNull();
+ expect(screen.getByTestId("terminal-s1")).toBeTruthy();
+ });
+
+ it("prunes a leaf whose session no longer exists, once the live set is known", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("dead");
+ render();
+ expect(screen.getByTestId("terminal-s1")).toBeTruthy();
+ expect(screen.queryByTestId("terminal-dead")).toBeNull();
+ });
+
+ it("does not touch the layout while the live set is still unknown", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render();
+ expect(screen.getByTestId("terminal-s1")).toBeTruthy();
+ });
+});
diff --git a/web/src/components/layout/MultiView.tsx b/web/src/components/layout/MultiView.tsx
new file mode 100644
index 0000000..de7acf8
--- /dev/null
+++ b/web/src/components/layout/MultiView.tsx
@@ -0,0 +1,203 @@
+// React port of multiview.ts's tab + split tree (issue #249). Preview mode
+// (docs/PREVIEW.md) is issue #251; sidebar drag-to-dock and the mobile
+// single-pane collapse stay with whichever issue wires this into the shell
+// (#253) — this renders the tree itself: tabs, nested splits, pane chrome.
+
+import type { ReactNode } from "react";
+import { useEffect, useRef } from "react";
+import { useStore } from "zustand";
+
+import { activeTab, type Dir, type Node } from "../../layout.js";
+import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
+import type { Session, Uuid } from "../../types.js";
+import { PaneContainer } from "./PaneContainer.js";
+import { PaneTitle } from "./PaneTitle.js";
+import { TabBar } from "./TabBar.js";
+
+export interface MultiViewProps {
+ store: LayoutStoreApi;
+ /** Live sessions, keyed by id — drives pane titles and (once known) boot
+ * reconciliation. Passing `undefined` defers reconciliation. */
+ sessions?: ReadonlyMap;
+ onDisconnect?: (sessionId: Uuid) => void;
+ emptyState?: ReactNode;
+}
+
+export function MultiView({ store, sessions, onDisconnect, emptyState }: MultiViewProps) {
+ const layout = useStore(store, (s: LayoutStore) => s.layout);
+ const tab = activeTab(layout);
+
+ const reconciledRef = useRef(false);
+ useEffect(() => {
+ if (reconciledRef.current || !sessions) return;
+ reconciledRef.current = true;
+ store.getState().reconcile(new Set(sessions.keys()));
+ }, [sessions, store]);
+
+ return (
+
+
+
+ {tab.root ? (
+
+ ) : (
+ (emptyState ?? (
+
+ No session open — pick one from the sidebar, or spawn a new one.
+
+ ))
+ )}
+
+
+ );
+}
+
+interface TreeProps {
+ tab: { id: string; focusedLeafId: string | null };
+ store: LayoutStoreApi;
+ sessions?: ReadonlyMap;
+ onDisconnect?: (sessionId: Uuid) => void;
+}
+
+function SplitNode({ node, ...rest }: TreeProps & { node: Node }) {
+ if (node.kind === "leaf") {
+ return ;
+ }
+ return ;
+}
+
+function SplitContainer({ node, ...rest }: TreeProps & { node: Extract }) {
+ const slotA = useRef(null);
+ const slotB = useRef(null);
+ const containerRef = useRef(null);
+
+ const onDividerPointerDown = (e: React.PointerEvent) => {
+ e.preventDefault();
+ const divider = e.currentTarget;
+ const container = containerRef.current;
+ if (!container) return;
+ divider.setPointerCapture(e.pointerId);
+ const rect = container.getBoundingClientRect();
+ const move = (ev: PointerEvent) => {
+ const frac =
+ node.dir === "row" ? (ev.clientX - rect.left) / rect.width : (ev.clientY - rect.top) / rect.height;
+ node.ratio = Math.max(0.1, Math.min(0.9, frac));
+ if (slotA.current) slotA.current.style.flexGrow = String(node.ratio);
+ if (slotB.current) slotB.current.style.flexGrow = String(1 - node.ratio);
+ };
+ const up = (ev: PointerEvent) => {
+ try {
+ divider.releasePointerCapture(ev.pointerId);
+ } catch {
+ // already released
+ }
+ divider.removeEventListener("pointermove", move);
+ divider.removeEventListener("pointerup", up);
+ rest.store.getState().commitLayout();
+ };
+ divider.addEventListener("pointermove", move);
+ divider.addEventListener("pointerup", up);
+ };
+
+ return (
+
+ );
+}
+
+function Pane({
+ leaf,
+ isFocused,
+ store,
+ sessions,
+ onDisconnect,
+}: TreeProps & { leaf: Extract; isFocused: boolean }) {
+ const session = leaf.sessionId ? sessions?.get(leaf.sessionId) : undefined;
+ const splitFocused = (dir: Dir) => store.getState().splitFocused(leaf.id, dir);
+
+ return (
+ store.getState().focusLeaf(leaf.id)}
+ >
+
+ {leaf.sessionId ? (
+
+ ) : (
+
Empty pane
+ )}
+
+ {leaf.sessionId && onDisconnect && (
+
+ )}
+
+
+
+
+
+
+ {leaf.sessionId ? (
+
store.getState().closeSession(leaf.sessionId!)}
+ />
+ ) : (
+
+ Empty pane — pick a session from the sidebar, or spawn one.
+
+ )}
+
+
+ );
+}
diff --git a/web/src/components/layout/PaneContainer.test.tsx b/web/src/components/layout/PaneContainer.test.tsx
new file mode 100644
index 0000000..0837fce
--- /dev/null
+++ b/web/src/components/layout/PaneContainer.test.tsx
@@ -0,0 +1,54 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { Session } from "../../types.js";
+import { PaneContainer } from "./PaneContainer.js";
+
+vi.mock("../terminal/TerminalPane.js", () => ({
+ TerminalPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
+
+ ),
+}));
+
+vi.mock("../structured/StructuredPane.js", () => ({
+ StructuredPane: ({ sessionId, focus }: { sessionId: string; focus?: boolean }) => (
+
+ ),
+}));
+
+afterEach(cleanup);
+
+function session(over: Partial = {}): Session {
+ return {
+ id: "s1",
+ cwd: "/home/n",
+ command: "claude",
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-01-01T00:00:00Z",
+ ...over,
+ } as Session;
+}
+
+describe("PaneContainer", () => {
+ it("renders TerminalPane when the session has no view (defaults to Terminal)", () => {
+ render();
+ expect(screen.getByTestId("terminal-pane")).toBeTruthy();
+ expect(screen.queryByTestId("structured-pane")).toBeNull();
+ });
+
+ it("renders TerminalPane when the session isn't known yet", () => {
+ render();
+ expect(screen.getByTestId("terminal-pane")).toBeTruthy();
+ });
+
+ it("renders StructuredPane for a Structured session", () => {
+ render();
+ const pane = screen.getByTestId("structured-pane");
+ expect(pane.dataset.sessionId).toBe("s1");
+ expect(pane.dataset.focus).toBe("false");
+ expect(screen.queryByTestId("terminal-pane")).toBeNull();
+ });
+});
diff --git a/web/src/components/layout/PaneContainer.tsx b/web/src/components/layout/PaneContainer.tsx
new file mode 100644
index 0000000..94047bf
--- /dev/null
+++ b/web/src/components/layout/PaneContainer.tsx
@@ -0,0 +1,49 @@
+// Dispatches a leaf's session to the right body — TerminalPane for a PTY
+// session, StructuredPane for an ACP one (session.view, #133/#140/#249).
+
+import { forwardRef, useImperativeHandle, useRef } from "react";
+
+import type { Session, Uuid } from "../../types.js";
+import { StructuredPane, type StructuredPaneHandle } from "../structured/StructuredPane.js";
+import { TerminalPane, type TerminalPaneHandle } from "../terminal/TerminalPane.js";
+
+export interface PaneContainerHandle {
+ focusTerminal(): void;
+ /** No-op for a structured pane — it has no PTY (pane.ts's PaneConnection contract). */
+ sendInput(data: string): void;
+}
+
+export interface PaneContainerProps {
+ sessionId: Uuid;
+ session?: Session;
+ /** Grabs the keyboard once connected. A sidebar preview passes `false`. */
+ focus?: boolean;
+ onClosed?: () => void;
+ className?: string;
+}
+
+export const PaneContainer = forwardRef(function PaneContainer(
+ { sessionId, session, focus, onClosed, className },
+ ref,
+) {
+ const terminalRef = useRef(null);
+ const structuredRef = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ focusTerminal: () => {
+ terminalRef.current?.focusTerminal();
+ structuredRef.current?.focusComposer();
+ },
+ sendInput: (data: string) => terminalRef.current?.sendInput(data),
+ }),
+ [],
+ );
+
+ return session?.view === "Structured" ? (
+
+ ) : (
+
+ );
+});
diff --git a/web/src/components/layout/PaneTitle.test.tsx b/web/src/components/layout/PaneTitle.test.tsx
new file mode 100644
index 0000000..cf82b4d
--- /dev/null
+++ b/web/src/components/layout/PaneTitle.test.tsx
@@ -0,0 +1,48 @@
+// @vitest-environment happy-dom
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+
+import type { Session } from "../../types.js";
+import { PaneTitle } from "./PaneTitle.js";
+
+afterEach(cleanup);
+
+function session(over: Partial = {}): Session {
+ return {
+ id: "s1",
+ cwd: "/home/n",
+ command: "claude",
+ env: {},
+ metadata: {},
+ status: "Running",
+ created_at: "2026-01-01T00:00:00Z",
+ ...over,
+ } as Session;
+}
+
+describe("PaneTitle", () => {
+ it("falls back to a short session id when the session isn't known yet", () => {
+ render();
+ expect(screen.getByText(/abcdef12/)).toBeTruthy();
+ });
+
+ it("shows the name alone when the command matches it", () => {
+ render();
+ expect(screen.getByText("claude")).toBeTruthy();
+ expect(document.querySelector(".at-cmd")).toBeNull();
+ });
+
+ it("shows the command alongside a custom name", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("editor")).toBeTruthy();
+ expect(document.querySelector(".at-cmd")).not.toBeNull();
+ });
+
+ it("never injects markup from a session name", () => {
+ render(" } })} />);
+ expect(document.querySelector("img")).toBeNull();
+ expect(screen.getByText("
")).toBeTruthy();
+ });
+});
diff --git a/web/src/components/layout/PaneTitle.tsx b/web/src/components/layout/PaneTitle.tsx
new file mode 100644
index 0000000..ba7fcb9
--- /dev/null
+++ b/web/src/components/layout/PaneTitle.tsx
@@ -0,0 +1,39 @@
+// React port of paneTitle.ts: a pane header's session identity — the name,
+// then the command as its tool mark when recognised, same as the sidebar
+// card's own CommandFragment (SessionCard.tsx). JSX escapes text nodes, so
+// unlike the vanilla string-builder this needs no manual HTML escaping.
+
+import { splitCommand, toolIconSvg } from "../../toolIcon.js";
+import { sessionName, type Session, type Uuid } from "../../types.js";
+
+export interface PaneTitleProps {
+ sessionId: Uuid;
+ session?: Session;
+ className?: string;
+}
+
+export function PaneTitle({ sessionId, session, className }: PaneTitleProps) {
+ if (!session) {
+ return (
+
+ session {sessionId.slice(0, 8)}
+
+ );
+ }
+
+ const name = sessionName(session);
+ const showCommand = Boolean(session.command) && session.command !== name;
+ const { tool, program } = splitCommand(session.command);
+ const icon = tool ? toolIconSvg(tool) : null;
+
+ return (
+
+ {name}
+ {showCommand && (
+
+ {icon ? : {program}}
+
+ )}
+
+ );
+}
diff --git a/web/src/components/layout/TabBar.test.tsx b/web/src/components/layout/TabBar.test.tsx
new file mode 100644
index 0000000..4dc111a
--- /dev/null
+++ b/web/src/components/layout/TabBar.test.tsx
@@ -0,0 +1,88 @@
+// @vitest-environment happy-dom
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { createLayoutStore } from "../../stores/layoutStore.js";
+import { TabBar } from "./TabBar.js";
+
+afterEach(cleanup);
+
+function store() {
+ return createLayoutStore({
+ load: () => null,
+ save: vi.fn(),
+ scheduler: { setTimer: () => 1, clearTimer: () => {} },
+ });
+}
+
+function dataTransfer() {
+ const data = new Map();
+ return {
+ setData: (type: string, value: string) => data.set(type, value),
+ getData: (type: string) => data.get(type) ?? "",
+ effectAllowed: "",
+ };
+}
+
+describe("TabBar", () => {
+ it("renders one tab per layout tab, marking the active one", () => {
+ const s = store();
+ s.getState().addTab();
+ render();
+ const tabs = screen.getAllByRole("tab");
+ expect(tabs).toHaveLength(2);
+ expect(tabs[1].getAttribute("aria-selected")).toBe("true");
+ expect(tabs[0].getAttribute("aria-selected")).toBe("false");
+ });
+
+ it("shows the open-session count next to a tab's title", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ render();
+ expect(screen.getByText(/· 1/)).toBeTruthy();
+ });
+
+ it("clicking a tab selects it", () => {
+ const s = store();
+ s.getState().addTab();
+ const [first] = s.getState().layout.tabs;
+ render();
+ fireEvent.click(screen.getAllByRole("tab")[0]);
+ expect(s.getState().layout.activeTabId).toBe(first.id);
+ });
+
+ it("the + button adds a tab", () => {
+ const s = store();
+ render();
+ fireEvent.click(screen.getByTitle("New tab"));
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ });
+
+ it("hides the close control on the last remaining tab", () => {
+ const s = store();
+ render();
+ expect(screen.queryByTitle("Close tab")).toBeNull();
+ });
+
+ it("closing a tab removes it", () => {
+ const s = store();
+ s.getState().addTab();
+ render();
+ fireEvent.click(screen.getAllByTitle("Close tab")[0]);
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ });
+
+ it("dragging a tab onto another reorders them", () => {
+ const s = store();
+ s.getState().addTab();
+ s.getState().addTab();
+ const [a, b, c] = s.getState().layout.tabs;
+ render();
+ const tabs = screen.getAllByRole("tab");
+ const dt = dataTransfer();
+ fireEvent.dragStart(tabs[0], { dataTransfer: dt });
+ fireEvent.dragOver(tabs[2], { dataTransfer: dt });
+ fireEvent.drop(tabs[2], { dataTransfer: dt });
+ expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([b.id, c.id, a.id]);
+ });
+});
diff --git a/web/src/components/layout/TabBar.tsx b/web/src/components/layout/TabBar.tsx
new file mode 100644
index 0000000..1a56185
--- /dev/null
+++ b/web/src/components/layout/TabBar.tsx
@@ -0,0 +1,91 @@
+// The tab strip above the canvas (issue #249): click to switch, drag to
+// reorder, a close ✕ per tab (hidden on the last one), a trailing + to add.
+
+import { useState } from "react";
+import { useStore } from "zustand";
+
+import { leaves } from "../../layout.js";
+import type { LayoutStore, LayoutStoreApi } from "../../stores/layoutStore.js";
+
+export interface TabBarProps {
+ store: LayoutStoreApi;
+}
+
+export function TabBar({ store }: TabBarProps) {
+ const layout = useStore(store, (s: LayoutStore) => s.layout);
+ const [draggingId, setDraggingId] = useState(null);
+ const [overId, setOverId] = useState(null);
+
+ return (
+
+ {layout.tabs.map((tab) => {
+ const count = leaves(tab.root).filter((l) => l.sessionId).length;
+ const isActive = tab.id === layout.activeTabId;
+ const isDropTarget = overId === tab.id && draggingId !== null && draggingId !== tab.id;
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/web/src/stores/layoutStore.test.ts b/web/src/stores/layoutStore.test.ts
new file mode 100644
index 0000000..b089ada
--- /dev/null
+++ b/web/src/stores/layoutStore.test.ts
@@ -0,0 +1,162 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { leaves } from "../layout.js";
+import { createLayoutStore, type LayoutStoreOptions } from "./layoutStore.js";
+
+function fakeScheduler() {
+ const timers = new Map void>();
+ let id = 0;
+ return {
+ scheduler: {
+ setTimer: (fn: () => void) => {
+ const h = ++id;
+ timers.set(h, fn);
+ return h;
+ },
+ clearTimer: (h: number) => {
+ timers.delete(h);
+ },
+ },
+ flush() {
+ for (const fn of [...timers.values()]) fn();
+ timers.clear();
+ },
+ };
+}
+
+function store(opts: Partial = {}) {
+ return createLayoutStore({
+ load: () => null,
+ save: vi.fn(),
+ scheduler: fakeScheduler().scheduler,
+ ...opts,
+ });
+}
+
+describe("layoutStore", () => {
+ it("boots with a single empty tab when nothing is persisted", () => {
+ const { layout } = store().getState();
+ expect(layout.tabs).toHaveLength(1);
+ expect(layout.tabs[0].root).toBeNull();
+ });
+
+ it("boots from a persisted layout when one loads", () => {
+ const persisted = store().getState().layout;
+ persisted.tabs[0].title = "Restored";
+ const s = store({ load: () => persisted });
+ expect(s.getState().layout.tabs[0].title).toBe("Restored");
+ });
+
+ it("openSession seeds the active tab from empty", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
+ });
+
+ it("openSession focuses an already-open session instead of moving it", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("s2");
+ s.getState().openSession("s1");
+ const ids = leaves(s.getState().layout.tabs[0].root)
+ .map((l) => l.sessionId)
+ .sort();
+ expect(ids).toEqual(["s1", "s2"]);
+ });
+
+ it("openSessionBeside splits next to the focused pane instead of replacing it", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("s2");
+ expect(leaves(s.getState().layout.tabs[0].root)).toHaveLength(2);
+ });
+
+ it("openSessionBeside(newTab) opens its own tab", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("s2", { newTab: true });
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ expect(leaves(s.getState().layout.tabs[1].root).map((l) => l.sessionId)).toEqual(["s2"]);
+ });
+
+ it("closeSession removes the pane and closes a tab left with nothing else", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().addTab();
+ s.getState().openSession("s2");
+ s.getState().closeSession("s2");
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ expect(leaves(s.getState().layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
+ });
+
+ it("splitFocused adds a leaf beside the given one and focuses it", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ const first = leaves(s.getState().layout.tabs[0].root)[0].id;
+ s.getState().splitFocused(first, "row");
+ const tab = s.getState().layout.tabs[0];
+ expect(leaves(tab.root)).toHaveLength(2);
+ expect(tab.focusedLeafId).not.toBe(first);
+ });
+
+ it("closePane collapses the split back to the surviving leaf", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ const first = leaves(s.getState().layout.tabs[0].root)[0].id;
+ s.getState().splitFocused(first, "row");
+ s.getState().closePane(first);
+ const remaining = leaves(s.getState().layout.tabs[0].root);
+ expect(remaining).toHaveLength(1);
+ expect(remaining[0].sessionId).toBeNull();
+ });
+
+ it("addTab/selectTab/closeTab manage the tab strip", () => {
+ const s = store();
+ s.getState().addTab();
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ const [first, second] = s.getState().layout.tabs;
+ s.getState().selectTab(first.id);
+ expect(s.getState().layout.activeTabId).toBe(first.id);
+ s.getState().closeTab(second.id);
+ expect(s.getState().layout.tabs).toHaveLength(1);
+ });
+
+ it("reorderTab moves a tab to sit where another one is", () => {
+ const s = store();
+ s.getState().addTab();
+ s.getState().addTab();
+ const [a, b, c] = s.getState().layout.tabs;
+ s.getState().reorderTab(c.id, a.id);
+ expect(s.getState().layout.tabs.map((t) => t.id)).toEqual([c.id, a.id, b.id]);
+ });
+
+ it("reconcile drops leaves for sessions no longer live, and closes a tab left with nothing else", () => {
+ const s = store();
+ s.getState().openSession("s1");
+ s.getState().addTab();
+ s.getState().openSession("dead");
+ s.getState().reconcile(new Set(["s1"]));
+ const { layout } = s.getState();
+ expect(layout.tabs).toHaveLength(1);
+ expect(leaves(layout.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1"]);
+ });
+
+ it("reconcile leaves an intentionally empty tab alone", () => {
+ const s = store();
+ s.getState().addTab();
+ s.getState().reconcile(new Set());
+ expect(s.getState().layout.tabs).toHaveLength(2);
+ });
+
+ it("debounces persistence to a single write once idle", () => {
+ const save = vi.fn();
+ const { scheduler, flush } = fakeScheduler();
+ const s = store({ save, scheduler });
+ s.getState().openSession("s1");
+ s.getState().openSessionBeside("s2");
+ expect(save).not.toHaveBeenCalled();
+ flush();
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(save.mock.calls[0][0]).toBe(s.getState().layout);
+ });
+});
diff --git a/web/src/stores/layoutStore.ts b/web/src/stores/layoutStore.ts
new file mode 100644
index 0000000..813e092
--- /dev/null
+++ b/web/src/stores/layoutStore.ts
@@ -0,0 +1,253 @@
+// Zustand wrapper around ../layout.js's layout tree model (issue #249).
+// Actions mutate the tree in place via layout.js's own functions, then
+// re-wrap it so `useStore` selectors relying on referential equality notice.
+
+import { create } from "zustand";
+
+import {
+ activeTab,
+ addTab as addTabToLayout,
+ closeTab as closeTabInLayout,
+ emptyLayout,
+ equalizeRatios,
+ findLeaf,
+ leaves,
+ locateSession,
+ removeLeaf,
+ splitLeaf,
+ type Dir,
+ type Layout,
+} from "../layout.js";
+import { loadLayout, saveLayout } from "../persistence.js";
+import type { Uuid } from "../types.js";
+import type { Scheduler } from "./catalogStore.js";
+
+const SAVE_DEBOUNCE_MS = 400;
+
+const browserScheduler: Scheduler = {
+ setTimer: (fn, ms) => window.setTimeout(fn, ms),
+ clearTimer: (h) => window.clearTimeout(h),
+};
+
+export interface LayoutStoreOptions {
+ load?: () => Layout | null;
+ save?: (layout: Layout) => void;
+ scheduler?: Scheduler;
+}
+
+export interface LayoutState {
+ layout: Layout;
+}
+
+export interface LayoutActions {
+ /** Bring `sessionId` into view: focus it if already open, else fill the
+ * focused pane (VS-Code-style replace), or seed an empty tab. */
+ openSession(sessionId: Uuid): void;
+ /** Same, but never evicts a running pane: reuses an empty leaf or splits
+ * beside the focused one. `newTab` opens its own tab instead. */
+ openSessionBeside(sessionId: Uuid, opts?: { newTab?: boolean }): void;
+ /** Drop the pane showing `sessionId` (closed/killed elsewhere). */
+ closeSession(sessionId: Uuid): void;
+ splitFocused(leafId: string, dir: Dir): void;
+ closePane(leafId: string): void;
+ focusLeaf(leafId: string): void;
+ selectTab(tabId: string): void;
+ addTab(): void;
+ closeTab(tabId: string): void;
+ /** Tab-strip drag-and-drop reordering: moves `fromId` to sit where `toId`
+ * currently is. */
+ reorderTab(fromId: string, toId: string): void;
+ /** Persist whatever a divider drag left on a split's `ratio` — the drag
+ * itself mutates the node directly (same trade as the vanilla version),
+ * this just re-wraps and schedules the save. */
+ commitLayout(): void;
+ /** Boot reconciliation: drop every leaf whose session isn't in `liveIds`,
+ * and close any tab that held nothing else. A session killed while its
+ * tab was closed is pruned instead of leaving a pane that can only ever
+ * show a dead socket. Tabs that were already empty are left alone. */
+ reconcile(liveIds: ReadonlySet): void;
+}
+
+export type LayoutStore = LayoutState & LayoutActions;
+export type LayoutStoreApi = ReturnType;
+
+/** Builds an isolated layout store — load/save/scheduler are injectable
+ * (tests), or default to the real localStorage persistence.ts + browser
+ * timers. Same shape as {@link createCatalogStore}. */
+export function createLayoutStore(opts: LayoutStoreOptions = {}) {
+ const load = opts.load ?? loadLayout;
+ const save = opts.save ?? saveLayout;
+ const scheduler = opts.scheduler ?? browserScheduler;
+
+ let saveTimer: number | null = null;
+
+ return create((set, get) => {
+ function touch(): void {
+ // A shallow clone, not the same reference back: `useStore` (and any
+ // other Object.is-based selector) needs the identity to change to
+ // notice a mutation made in place on the tree underneath.
+ set({ layout: { ...get().layout } });
+ }
+
+ function schedulePersist(): void {
+ if (saveTimer !== null) scheduler.clearTimer(saveTimer);
+ saveTimer = scheduler.setTimer(() => {
+ saveTimer = null;
+ save(get().layout);
+ }, SAVE_DEBOUNCE_MS);
+ }
+
+ function commit(): void {
+ touch();
+ schedulePersist();
+ }
+
+ function mutate(fn: (layout: Layout) => void): void {
+ fn(get().layout);
+ commit();
+ }
+
+ return {
+ layout: load() ?? emptyLayout(),
+
+ openSession(sessionId) {
+ mutate((layout) => {
+ const loc = locateSession(layout, sessionId);
+ if (loc) {
+ layout.activeTabId = loc.tab.id;
+ loc.tab.focusedLeafId = loc.leaf.id;
+ return;
+ }
+ const tab = activeTab(layout);
+ if (!tab.root) {
+ tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
+ return;
+ }
+ const target = (tab.focusedLeafId && findLeaf(tab.root, tab.focusedLeafId)) || leaves(tab.root)[0];
+ target.sessionId = sessionId;
+ tab.focusedLeafId = target.id;
+ });
+ },
+
+ openSessionBeside(sessionId, placement) {
+ mutate((layout) => {
+ const loc = locateSession(layout, sessionId);
+ if (loc) {
+ layout.activeTabId = loc.tab.id;
+ loc.tab.focusedLeafId = loc.leaf.id;
+ return;
+ }
+ if (placement?.newTab) {
+ const fresh = addTabToLayout(layout);
+ fresh.focusedLeafId = splitLeaf(fresh, "", sessionId, "row", true);
+ return;
+ }
+ const tab = activeTab(layout);
+ if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
+ tab.focusedLeafId = splitLeaf(tab, "", sessionId, "row", true);
+ return;
+ }
+ const target = findLeaf(tab.root, tab.focusedLeafId)!;
+ if (!target.sessionId) {
+ target.sessionId = sessionId;
+ } else {
+ tab.focusedLeafId = splitLeaf(tab, target.id, sessionId, "row", true);
+ equalizeRatios(tab.root);
+ }
+ });
+ },
+
+ closeSession(sessionId) {
+ mutate((layout) => {
+ const loc = locateSession(layout, sessionId);
+ if (!loc) return;
+ removeLeaf(loc.tab, loc.leaf.id);
+ if (loc.tab.focusedLeafId === loc.leaf.id) {
+ loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null;
+ }
+ if (!loc.tab.root) closeTabInLayout(layout, loc.tab.id);
+ });
+ },
+
+ splitFocused(leafId, dir) {
+ mutate((layout) => {
+ const tab = activeTab(layout);
+ tab.focusedLeafId = splitLeaf(tab, leafId, null, dir, true);
+ equalizeRatios(tab.root);
+ });
+ },
+
+ closePane(leafId) {
+ mutate((layout) => {
+ const tab = activeTab(layout);
+ removeLeaf(tab, leafId);
+ if (tab.focusedLeafId === leafId) {
+ tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
+ }
+ });
+ },
+
+ focusLeaf(leafId) {
+ mutate((layout) => {
+ const tab = activeTab(layout);
+ if (tab.focusedLeafId === leafId) return;
+ tab.focusedLeafId = leafId;
+ });
+ },
+
+ selectTab(tabId) {
+ mutate((layout) => {
+ if (layout.tabs.some((t) => t.id === tabId)) layout.activeTabId = tabId;
+ });
+ },
+
+ addTab() {
+ mutate((layout) => {
+ addTabToLayout(layout);
+ });
+ },
+
+ closeTab(tabId) {
+ mutate((layout) => {
+ closeTabInLayout(layout, tabId);
+ });
+ },
+
+ reorderTab(fromId, toId) {
+ mutate((layout) => {
+ if (fromId === toId) return;
+ const from = layout.tabs.findIndex((t) => t.id === fromId);
+ const to = layout.tabs.findIndex((t) => t.id === toId);
+ if (from < 0 || to < 0) return;
+ const [moved] = layout.tabs.splice(from, 1);
+ layout.tabs.splice(to, 0, moved);
+ });
+ },
+
+ commitLayout() {
+ commit();
+ },
+
+ reconcile(liveIds) {
+ mutate((layout) => {
+ const emptied: string[] = [];
+ for (const tab of [...layout.tabs]) {
+ let prunedAny = false;
+ for (const leaf of leaves(tab.root)) {
+ if (leaf.sessionId && !liveIds.has(leaf.sessionId)) {
+ removeLeaf(tab, leaf.id);
+ prunedAny = true;
+ }
+ }
+ if (!prunedAny) continue;
+ if (!tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) {
+ tab.focusedLeafId = leaves(tab.root)[0]?.id ?? null;
+ }
+ if (!tab.root) emptied.push(tab.id);
+ }
+ for (const id of emptied) closeTabInLayout(layout, id);
+ });
+ },
+ };
+ });
+}