feat(maestro): persist multi-view layout per device
ci / rust (push) Successful in 5m21s

Save the layout tree (tabs + splits + focus + ratios) to localStorage,
debounced, on every structural/focus/resize change; restore it on boot. Load
rebuilds with fresh node ids and defensively drops malformed data — a corrupt or
versioned-out blob falls back to a single empty tab. Sessions that no longer
exist when restored aren't pruned eagerly (the session list isn't known at
boot); their panes attach, the broker closes the dead socket, and the pane
auto-closes.

Closes #34. Part of epic #37.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 09:24:57 +00:00
co-authored by Claude Opus 4.8
parent 159b7ecd6e
commit d272210ecd
3 changed files with 114 additions and 3 deletions
+20 -3
View File
@@ -5,6 +5,7 @@ import { CatalogStore } from "./events.js";
import { mountHud, type Hud } from "./hud.js";
import { emptyLayout, type Layout, openSessions } from "./layout.js";
import { createMultiView, type MultiView } from "./multiview.js";
import { loadLayout, saveLayout } from "./persistence.js";
import { openSpawnModal } from "./spawn.js";
import { initTheme } from "./theme.js";
import { sessionName } from "./types.js";
@@ -106,8 +107,16 @@ hud = mountHud(root, store, () => pool.focused());
// ---- layout state ----
// Single source of truth for what's mounted: tabs of split trees. The hash route
// is just a deep-link into it (`#/attach/:id` focuses/opens a session).
const layout: Layout = emptyLayout();
// is just a deep-link into it (`#/attach/:id` focuses/opens a session). Restored
// from localStorage per device (issue #34); dead sessions self-prune on attach.
const layout: Layout = loadLayout() ?? emptyLayout();
// Debounced persist — the controller calls this after every layout change.
let saveTimer: number | undefined;
function scheduleSave(): void {
clearTimeout(saveTimer);
saveTimer = window.setTimeout(() => saveLayout(layout), 400);
}
function infoFor(sessionId: string) {
const s = store.snapshot().sessions.get(sessionId);
@@ -155,7 +164,15 @@ function renderNotFound(raw: string): HTMLElement {
// The multi-view controller renders tabs + splits inside the content pane and
// mounts a live attach into each session leaf.
mv = createMultiView({ pool, layout, sidebar, infoFor, onDisconnect: closePane, renderWelcome });
mv = createMultiView({
pool,
layout,
sidebar,
infoFor,
onDisconnect: closePane,
renderWelcome,
onLayoutChange: scheduleSave,
});
let contentShowsMv = false;
function showMultiView(): void {
if (!contentShowsMv) {
+8
View File
@@ -33,6 +33,9 @@ export interface MultiViewOptions {
onDisconnect: (sessionId: string) => void;
/** Welcome content for an empty tab. */
renderWelcome: () => HTMLElement;
/** Called after any layout change (structure, focus, or divider resize) so the
* shell can persist it (issue #34). */
onLayoutChange?: () => void;
}
export interface MultiView {
@@ -53,6 +56,7 @@ export interface MultiView {
export function createMultiView(opts: MultiViewOptions): MultiView {
const { pool, layout, sidebar, infoFor, onDisconnect, renderWelcome } = opts;
const persist = () => opts.onLayoutChange?.();
const el = document.createElement("div");
el.className = "mv";
@@ -96,6 +100,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
if (tab.focusedLeafId === leafId) return;
tab.focusedLeafId = leafId;
syncFocus();
persist();
}
// ---- pane + split DOM ----
@@ -193,6 +198,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
}
divider.removeEventListener("pointermove", move);
divider.removeEventListener("pointerup", up);
persist(); // save the new split ratio
};
divider.addEventListener("pointermove", move);
divider.addEventListener("pointerup", up);
@@ -320,6 +326,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
pool.setFocus(null);
canvas.appendChild(renderWelcome());
sidebar.setHighlights({ open: openSessions(layout), focused: null, colors: colorMap() });
persist();
return;
}
@@ -332,6 +339,7 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
canvas.appendChild(buildTree(tab.root, hosts));
pool.setVisible(hosts.map((h) => ({ id: h.id, pane: h.host, info: h.info })));
syncFocus();
persist();
}
function closeSession(sessionId: string): void {
+86
View File
@@ -0,0 +1,86 @@
// Per-device persistence of the multi-view layout (issue #34). The layout tree
// is plain data, so it serialises straight to localStorage. On load we rebuild
// with fresh node ids (the id counter resets each boot) and defensively drop
// anything malformed — a corrupt or stale blob falls back to a single empty tab.
//
// Sessions that no longer exist when a layout is restored are not pruned here
// (the session list isn't known yet at boot); their panes attach, the broker
// closes the dead socket, and the pane auto-closes (see ConnectionPool.onClosed).
import { freshId, type Layout, type Node, type Tab } from "./layout.js";
const KEY = "maestro-layout-v1";
const VERSION = 1;
export function saveLayout(layout: Layout): void {
try {
localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout }));
} catch {
// Storage full or unavailable (private mode) — persistence is best-effort.
}
}
export function loadLayout(): Layout | null {
let raw: string | null;
try {
raw = localStorage.getItem(KEY);
} catch {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as { v?: number; layout?: unknown };
if (parsed.v !== VERSION) return null;
return hydrate(parsed.layout);
} catch {
return null;
}
}
function hydrate(raw: unknown): Layout | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as { tabs?: unknown; activeTabId?: unknown };
if (!Array.isArray(r.tabs) || r.tabs.length === 0) return null;
const tabs: Tab[] = [];
let activeTabId = "";
for (const rt of r.tabs as Array<Record<string, unknown>>) {
const id = freshId("t");
let focusedLeafId: string | null = null;
const root = hydrateNode(rt.root, rt.focusedLeafId, (fid) => {
focusedLeafId = fid;
});
tabs.push({
id,
title: typeof rt.title === "string" ? rt.title : `Tab ${tabs.length + 1}`,
root,
focusedLeafId,
});
if (rt.id === r.activeTabId) activeTabId = id;
}
if (!activeTabId) activeTabId = tabs[0].id;
return { tabs, activeTabId };
}
function hydrateNode(
raw: unknown,
oldFocused: unknown,
onFocus: (newId: string) => void,
): Node | null {
if (!raw || typeof raw !== "object") return null;
const n = raw as Record<string, unknown>;
if (n.kind === "leaf") {
const id = freshId("l");
if (n.id === oldFocused) onFocus(id);
return { kind: "leaf", id, sessionId: typeof n.sessionId === "string" ? n.sessionId : null };
}
if (n.kind === "split" && (n.dir === "row" || n.dir === "col")) {
const a = hydrateNode(n.a, oldFocused, onFocus);
const b = hydrateNode(n.b, oldFocused, onFocus);
if (!a) return b;
if (!b) return a;
const ratio = typeof n.ratio === "number" && n.ratio > 0.05 && n.ratio < 0.95 ? n.ratio : 0.5;
return { kind: "split", dir: n.dir, a, b, ratio };
}
return null;
}