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);