From 6f8de8de7c0946ce2c874fe3d4a1c449fa0df66c Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Sun, 23 Aug 2026 20:12:28 +0100 Subject: [PATCH] fix(maestro): draft-pane regressions from the integration review Six findings from reviewing the accumulated blitz/spawn-v2 diff: - loadRecents restored the seeded cwd but never emitted it, so the leaf's draft kept cwd:"" and the next render reseeded the form blank. - stripDrafts did not recurse, so every half-filled draft in a split tab was written to localStorage; the old test passed only because hydrate discards draft on the way back in. - showRawCwd and selectPreset grabbed focus from async init paths, so a draft pane stole the caret seconds after mounting. - .mv-draft inherited no layout once the form left .modal-body. - onSuccess mounted the new session onto a leaf that may have been closed while the POST was in flight, stranding it in no pane. --- web/src/multiview.ts | 12 +++++++++--- web/src/persistence.test.ts | 26 ++++++++++++++++++++++++++ web/src/persistence.ts | 7 ++++++- web/src/spawnForm.ts | 20 +++++++++++++------- web/src/styles.css | 6 ++++++ 5 files changed, 60 insertions(+), 11 deletions(-) diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 725fd53..f31e607 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -152,10 +152,16 @@ export function createMultiView(opts: MultiViewOptions): MultiView { leaf.draft = values; }, onSuccess: (session) => { - leaf.sessionId = session.id; - leaf.draft = null; + // The pane can be closed while the POST is in flight. Mounting the + // session onto a leaf no longer in the tree would strand it in no + // pane at all, so only claim the leaf while it is still there. const t = activeTab(layout); - if (leaves(t.root).includes(leaf)) t.focusedLeafId = leaf.id; + const attached = leaves(t.root).includes(leaf); + if (attached) { + leaf.sessionId = session.id; + leaf.draft = null; + t.focusedLeafId = leaf.id; + } render(); opts.onOpenSessionsChange?.(); }, diff --git a/web/src/persistence.test.ts b/web/src/persistence.test.ts index c038d46..a626f58 100644 --- a/web/src/persistence.test.ts +++ b/web/src/persistence.test.ts @@ -99,4 +99,30 @@ describe("serializeLayout", () => { } expect(leaves(restored!.tabs[0].root).map((l) => l.sessionId)).toEqual(["s1", null]); }); + + it("keeps the draft out of the stored blob itself, not just out of hydrate", () => { + // A draft pane only exists once a tab has split, so the strip has to walk + // into split branches; asserting on the restored layout alone passes even + // when the draft was written to localStorage, because hydrate drops it. + 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/secret-project", name: "amber-fox" }; + + const raw = serializeLayout(layout); + expect(raw).not.toContain("secret-project"); + expect(raw).not.toContain("amber-fox"); + }); }); diff --git a/web/src/persistence.ts b/web/src/persistence.ts index 708e36b..4a60195 100644 --- a/web/src/persistence.ts +++ b/web/src/persistence.ts @@ -34,7 +34,12 @@ export function serializeLayout(layout: Layout): string { } function stripDrafts(node: Node | null): Node | null { - if (!node || node.kind !== "leaf") return node; + if (!node) return node; + // Recurse: a draft pane only exists once a tab has split, so a shallow strip + // would persist every half-filled form it was meant to drop. + if (node.kind === "split") { + return { ...node, a: stripDrafts(node.a)!, b: stripDrafts(node.b)! }; + } return { ...node, draft: null }; } diff --git a/web/src/spawnForm.ts b/web/src/spawnForm.ts index 26e1a3b..7b78eb4 100644 --- a/web/src/spawnForm.ts +++ b/web/src/spawnForm.ts @@ -209,14 +209,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { syncBranchToName(); // seed the branch from the initial name // ---- command tiles --------------------------------------------------- - function selectPreset(id: string): void { + // `focus` is false for the two initial applications (construction, and again + // when `/api/info` resolves): a draft pane must not grab the caret out from + // under the operator seconds after it mounts. + function selectPreset(id: string, focus = true): void { selectedPresetId = id; const preset = presets.find((p) => p.id === id); const isCustom = !preset || preset.command === ""; cmdRaw.hidden = !isCustom; if (isCustom) { command = cmdRaw.value.trim(); - cmdRaw.focus(); + if (focus) cmdRaw.focus(); } else { command = preset!.command; } @@ -270,17 +273,17 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { if (wanted) { const match = presets.find((p) => p.command === wanted && p.command !== ""); if (match) { - selectPreset(match.id); + selectPreset(match.id, false); } else { const custom = presets.find((p) => p.command === ""); cmdRaw.value = wanted; - selectPreset(custom ? custom.id : "custom"); + selectPreset(custom ? custom.id : "custom", false); command = wanted; } return; } const first = presets.find((p) => p.command !== ""); - if (first) selectPreset(first.id); + if (first) selectPreset(first.id, false); } // ---- working-directory picker --------------------------------------- @@ -345,17 +348,20 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle { cwdRaw.value = seedCwd; markActiveRecent(cwd); } else { - showRawCwd(seedCwd); + showRawCwd(seedCwd, false); } seedCwd = null; } else if (dirs.length === 0) { // No history: default to raw input prefilled with the daemon's home dir. - showRawCwd(homeDir); + showRawCwd(homeDir, false); } else { cwdRawWrap.hidden = true; } // The seeded cwd is in place — a restored worktree can probe now. if (wtEnabled.checked) void probeGitRoot(); + // Publish it: construction ran `emit()` with an empty cwd, so without this + // the leaf's draft keeps `cwd: ""` and the next render reseeds it blank. + emit(); } function recentRow(dir: RecentDir): HTMLElement { diff --git a/web/src/styles.css b/web/src/styles.css index cd01222..93374c4 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -2079,6 +2079,12 @@ textarea { overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; + /* The modal form got its stacking and padding from `.modal-body`; in a pane + the form is the host, so it carries them itself. */ + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-3); } /* pane tools cluster (disconnect / split / close), top-right corner. The attach