feat(maestro): hang draft state off layout leaves, never persisted (#57)

This commit is contained in:
Miguel Palhas
2026-08-23 19:52:18 +01:00
parent 53660407e3
commit 10cb47c50b
3 changed files with 76 additions and 5 deletions
+17 -2
View File
@@ -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<Node, { kind: "leaf" }>;
@@ -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;
+40 -2
View File
@@ -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]);
});
});
+19 -1
View File
@@ -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 {