From d1015748425c019d6467d74bb85c97b159095635 Mon Sep 17 00:00:00 2001 From: Miguel Palhas Date: Mon, 31 Aug 2026 09:16:15 +0100 Subject: [PATCH] feat(maestro): close a tab when its last session dies Killing the only session in a tab left an empty tab behind for the operator to close by hand. The pane's tab now goes with it, unless it is the last tab (which stays, emptied). --- web/src/multiview.test.ts | 97 ++++++++++++++++++++++++++++++++++++++- web/src/multiview.ts | 4 ++ 2 files changed, 100 insertions(+), 1 deletion(-) diff --git a/web/src/multiview.test.ts b/web/src/multiview.test.ts index a60d694..253715f 100644 --- a/web/src/multiview.test.ts +++ b/web/src/multiview.test.ts @@ -1,7 +1,7 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emptyLayout, leaves, type Layout } from "./layout.js"; +import { addTab, emptyLayout, leaves, type Layout } from "./layout.js"; import { splitLeaf } from "./layout.js"; import { createMultiView, type MultiView } from "./multiview.js"; import type { ConnectionPool } from "./connections.js"; @@ -333,3 +333,98 @@ describe("openDraft newTab (#100)", () => { expect(leaves(tab.root)).toHaveLength(2); }); }); + +describe("multiview closeSession tab cleanup (#137)", () => { + beforeEach(() => { + stubFetch(); + document.body.replaceChildren(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("closes the tab when its last session goes away", () => { + const layout = emptyLayout(); + const first = layout.tabs[0]; + first.focusedLeafId = splitLeaf(first, "", "s1", "row", true); + const second = addTab(layout); + second.focusedLeafId = splitLeaf(second, "", "s2", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.closeSession("s2"); + + expect(layout.tabs).toHaveLength(1); + expect(layout.tabs[0].id).toBe(first.id); + expect(layout.activeTabId).toBe(first.id); + }); + + it("closes a background tab without stealing focus from the active one", () => { + const layout = emptyLayout(); + const background = layout.tabs[0]; + background.focusedLeafId = splitLeaf(background, "", "s1", "row", true); + const active = addTab(layout); + active.focusedLeafId = splitLeaf(active, "", "s2", "row", true); + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.closeSession("s1"); + + expect(layout.tabs).toHaveLength(1); + expect(layout.tabs[0].id).toBe(active.id); + expect(layout.activeTabId).toBe(active.id); + }); + + it("keeps a tab that still holds another pane", () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const first = splitLeaf(tab, "", "s1", "row", true); + tab.focusedLeafId = splitLeaf(tab, first, "s2", "row", true); + addTab(layout); + layout.activeTabId = tab.id; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + + mv.closeSession("s2"); + + expect(layout.tabs).toHaveLength(2); + expect(leaves(tab.root)).toHaveLength(1); + }); + + it("keeps a tab holding a draft pane", async () => { + const layout = emptyLayout(); + const tab = layout.tabs[0]; + const s1 = splitLeaf(tab, "", "s1", "row", true); + splitLeaf(tab, s1, null, "row", true); + addTab(layout); + layout.activeTabId = tab.id; + const mv = makeMv(layout); + document.body.appendChild(mv.el); + mv.render(); + await flush(); + + mv.closeSession("s1"); + + expect(layout.tabs).toHaveLength(2); + expect(leaves(tab.root)).toHaveLength(1); + }); + + it("never closes the only tab", () => { + 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.closeSession("s1"); + + expect(layout.tabs).toHaveLength(1); + expect(layout.tabs[0].root).toBeNull(); + expect(layout.activeTabId).toBe(tab.id); + }); +}); diff --git a/web/src/multiview.ts b/web/src/multiview.ts index b459c81..f19de89 100644 --- a/web/src/multiview.ts +++ b/web/src/multiview.ts @@ -553,6 +553,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView { if (loc.tab.focusedLeafId === loc.leaf.id) { loc.tab.focusedLeafId = leaves(loc.tab.root)[0]?.id ?? null; } + // A tab that held nothing but the dead session goes with it (#137): a killed + // session should not leave an empty tab behind for the operator to close by + // hand. `closeTab` keeps the last tab (emptied) so there is always one. + if (!loc.tab.root) closeTab(layout, loc.tab.id); render(); }