From ad2e700391a44529db0124139a38bd070e0876f9 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 24 Aug 2026 17:17:35 +0100 Subject: [PATCH] fix(maestro): spawn opens a new tab instead of splitting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pressing Spawn while reading a session split that pane in half. It now opens the draft in its own tab, leaving the current tab untouched. Scoped to the explicit Spawn affordances (the header button and the sidebar empty state, both via OPEN_SPAWN_EVENT). "Spawn similar" and the fast-forward key deliberately keep splitting: both act on the session in front of you, so a pane beside it is the point. Reusing an empty pane (#72) is unchanged — that path never split anything. Closes #100 --- web/src/main.ts | 6 ++++- web/src/multiview.test.ts | 51 +++++++++++++++++++++++++++++++++++++++ web/src/multiview.ts | 15 +++++++++--- 3 files changed, 68 insertions(+), 4 deletions(-) diff --git a/web/src/main.ts b/web/src/main.ts index 6b2a26a..1d10746 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -272,7 +272,11 @@ function showMultiView(): void { // cloned session's dir + command. function requestOpenSpawn(): void { showMultiView(); - mv.openDraft(); + // A new tab, not a split of the pane you were reading (#100). "Spawn + // similar" and the fast-forward key below deliberately keep the old + // behaviour: both act on the session in front of you, so a split beside it + // is the point. + mv.openDraft(undefined, { newTab: true }); } document.addEventListener(OPEN_SPAWN_EVENT, () => requestOpenSpawn()); document.addEventListener("catalog-spawn-similar", (ev) => { diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index a08bf79..a60d694 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -282,3 +282,54 @@ describe("multiview pane-drag swap (#73)", () => { expect(nameInputOf(leaf2).value).toBe("west"); }); }); + +describe("openDraft newTab (#100)", () => { + beforeEach(() => { + stubFetch(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens its own tab instead of splitting the focused session", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + tab.focusedLeafId = first; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(undefined, { newTab: true }); + await flush(); + + expect(layout.tabs).toHaveLength(2); + // The tab you were reading is untouched: still one pane, still its session. + const original = leaves(layout.tabs[0].root); + expect(original).toHaveLength(1); + expect(original[0].sessionId).toBe("s1"); + // The draft is alone in the new, now-active tab. + const fresh = leaves(layout.tabs[1].root); + expect(fresh).toHaveLength(1); + expect(fresh[0].sessionId).toBeNull(); + expect(fresh[0].draft).not.toBeNull(); + expect(layout.activeTabId).toBe(layout.tabs[1].id); + }); + + it("still splits when newTab is not asked for", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.openDraft(); + await flush(); + + expect(layout.tabs).toHaveLength(1); + expect(leaves(tab.root)).toHaveLength(2); + }); +}); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index 3ea9a94..b459c81 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -57,7 +57,7 @@ export interface MultiView { /** Open a draft pane (issue #62): reuse the focused pane if it is already * empty, otherwise split the focused pane. `seed` pre-fills the form * ("Spawn similar" clones a session's cwd + command). */ - openDraft: (seed?: Partial) => void; + openDraft: (seed?: Partial, opts?: { newTab?: boolean }) => void; /** Start dragging a sidebar session into the canvas (issue #32). The * controller owns drag-vs-tap: a tap lets the row's normal click open it, a * drag shows drop zones to place it. */ @@ -412,9 +412,18 @@ export function createMultiView(opts: MultiViewOptions): MultiView { opts.onOpenSessionsChange?.(); } - function openDraft(seed?: Partial): void { - const tab = activeTab(layout); + function openDraft(seed?: Partial, opts?: { newTab?: boolean }): void { const draft = (): Draft => ({ command: "", cwd: "", name: "", ...seed }); + if (opts?.newTab) { + // Spawn opens its own tab rather than halving whatever you were reading + // (#100). The empty-pane reuse below still applies within a tab. + const fresh = addTab(layout); + fresh.focusedLeafId = splitLeaf(fresh, "", null, "row", true); + findLeaf(fresh.root!, fresh.focusedLeafId)!.draft = draft(); + render(); + return; + } + const tab = activeTab(layout); if (!tab.root || !tab.focusedLeafId || !findLeaf(tab.root, tab.focusedLeafId)) { // Empty tab (or stale focus): the draft becomes the first pane. tab.focusedLeafId = splitLeaf(tab, "", null, "row", true);