From 10cb47c50b81556c81b8ecea7f5604ab0623d6c3 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 19:52:18 +0100 Subject: [PATCH] feat(maestro): hang draft state off layout leaves, never persisted (#57) --- web/src/layout.ts | 19 +++++++++++++++-- web/src/persistence.test.ts | 42 +++++++++++++++++++++++++++++++++++-- web/src/persistence.ts | 20 +++++++++++++++++- 3 files changed, 76 insertions(+), 5 deletions(-) diff --git a/web/src/layout.ts b/web/src/layout.ts index 51a2ec5..bf98155 100644 --- a/web/src/layout.ts +++ b/web/src/layout.ts @@ -11,8 +11,23 @@ import type { Uuid } from "./types.js"; export type Dir = "row" | "col"; +/** Half-filled spawn-form state for a draft pane (issue #57): an empty leaf + * renders the spawn form instead of a placeholder, and each pane's values + * hang off its own leaf so two drafts stay independent. Never persisted — + * a reload gives back an empty form (persistence strips it). */ +export interface Draft { + command: string; + cwd: string; + name: string; + worktreeEnabled?: boolean; + worktreeBranch?: string; + worktreeCleanup?: boolean; + /** Whether the cwd lives in the raw text input (vs a picked recent row). */ + cwdRawVisible?: boolean; +} + export type Node = - | { kind: "leaf"; id: string; sessionId: Uuid | null } + | { kind: "leaf"; id: string; sessionId: Uuid | null; draft?: Draft | null } | { kind: "split"; dir: Dir; a: Node; b: Node; ratio: number }; export type Leaf = Extract; @@ -89,7 +104,7 @@ export function splitLeaf( dir: Dir, after: boolean, ): string { - const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId }; + const fresh: Leaf = { kind: "leaf", id: freshId("l"), sessionId, draft: null }; const rec = (n: Node): Node => { if (n.kind === "leaf") { if (n.id !== targetId) return n; diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index ffc00e5..c038d46 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { hydrate } from "./persistence.js"; -import { leaves } from "./layout.js"; +import { hydrate, serializeLayout } from "./persistence.js"; +import { leaves, type Layout } from "./layout.js"; describe("hydrate", () => { it("rejects anything that is not a tab-bearing object", () => { @@ -61,4 +61,42 @@ describe("hydrate", () => { }); expect(leaves(layout!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s"]); }); + + it("never restores draft state, even if a stale blob carries it", () => { + const layout = hydrate({ + tabs: [ + { id: "t", root: { kind: "leaf", id: "l1", sessionId: null, draft: { command: "zsh", cwd: "/x" } } }, + ], + activeTabId: "t", + }); + expect(leaves(layout!.tabs[0].root)[0]).not.toHaveProperty("draft"); + }); +}); + +describe("serializeLayout", () => { + it("strips draft state so a reload gives back an empty form", () => { + const layout = hydrate({ + tabs: [ + { + id: "t", + root: { + kind: "split", + dir: "row", + a: { kind: "leaf", id: "l1", sessionId: "s1" }, + b: { kind: "leaf", id: "l2", sessionId: null }, + }, + }, + ], + activeTabId: "t", + }) as Layout; + const draftLeaf = leaves(layout.tabs[0].root).find((l) => !l.sessionId)!; + draftLeaf.draft = { command: "claude", cwd: "/home/user/project", name: "amber-fox" }; + + const restored = hydrate(JSON.parse(serializeLayout(layout)).layout); + expect(restored).not.toBeNull(); + for (const leaf of leaves(restored!.tabs[0].root)) { + expect(leaf.draft ?? null).toBeNull(); + } + expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); + }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 4ac03b9..708e36b 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -14,12 +14,30 @@ const VERSION = 1; export function saveLayout(layout: Layout): void { try { - localStorage.setItem(KEY, JSON.stringify({ v: VERSION, layout })); + localStorage.setItem(KEY, serializeLayout(layout)); } catch { // Storage full or unavailable (private mode) — persistence is best-effort. } } +/** Serialise the layout for storage with draft state stripped (issue #57): a + * reload gives back an empty form, never a stale half-filled one. */ +export function serializeLayout(layout: Layout): string { + const clean: Layout = { + ...layout, + tabs: layout.tabs.map((t) => ({ + ...t, + root: stripDrafts(t.root), + })), + }; + return JSON.stringify({ v: VERSION, layout: clean }); +} + +function stripDrafts(node: Node | null): Node | null { + if (!node || node.kind !== "leaf") return node; + return { ...node, draft: null }; +} + export function loadLayout(): Layout | null { let raw: string | null; try {