diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index b4cd9fe1900e..1344ed9ce75d 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -190,6 +190,28 @@ test("new tab selection and closing stay in the focused pane", async ({ page }) await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") }) +test("closing the last New tab closes the side region, and only a New tab lists the launchers", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + await openSession(page, { name: "LastNewTab", seed: { panes: { ses_lastnewtab: { review: true } } } }) + + const panel = page.locator("#review-panel") + const toggle = page.getByRole("button", { name: "Toggle review", exact: true }) + const newTab = panel.getByRole("tab", { name: "New tab", exact: true }) + const review = panel.getByRole("button", { name: /^Review/ }) + + // An empty side region opens on a New tab listing the launchers. + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() + + // Closing it leaves no tab, so the region closes rather than listing the launchers without one. + await newTab.locator("..").getByRole("button", { name: "Close tab" }).click() + await expect(toggle).toHaveAttribute("aria-expanded", "false") + + await toggle.click() + await expect(newTab).toHaveAttribute("aria-selected", "true") + await expect(review).toBeVisible() +}) + // A DOM mark survives only while the same node stays mounted. function mark(locator: Locator) { return locator.evaluate((element) => element.setAttribute("data-e2e-mounted", "original")) diff --git a/packages/app/src/runtime/plugin/panels.tsx b/packages/app/src/runtime/plugin/panels.tsx index f84aaccf9962..91c61637fa9e 100644 --- a/packages/app/src/runtime/plugin/panels.tsx +++ b/packages/app/src/runtime/plugin/panels.tsx @@ -112,7 +112,7 @@ export function createRegion(input: { open: (key: string) => void /** Whether the side region is open, and the user's toggle for it. */ side: Accessor<{ opened(): boolean; toggle(): void }> - /** Runs when a close leaves no stored tab, in the same batch as the close. */ + /** Runs when a close leaves no stored tab and no New tab, in the same batch as the close. */ onEmptied?: () => void /** Runs work that forces a layout, like moving focus, once the region's open motion lands. */ settled?: (run: () => void) => void @@ -327,6 +327,14 @@ export function createRegion(input: { if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live)) }) + // Closing the last New tab of a strip with no tabs closes the region, as closing its last tab does. + const dismissNewTab = (id: string) => + batch(() => { + setState("adding", (list) => list.filter((value) => value.id !== id)) + + if (stored().length === 0 && !state.adding.some(live)) input.onEmptied?.() + }) + return { /** Whether a key event belongs to the side region rather than the session around it. */ focus: createSideFocus(), @@ -372,15 +380,13 @@ export function createRegion(input: { setState("chosen", pane, id) }) }, - dismissNewTab(id: string) { - setState("adding", (list) => list.filter((value) => value.id !== id)) - }, + dismissNewTab, /** Closes the shown new tab view in the given or active pane; returns whether one was showing. */ closeNewTab(pane: SessionPane = activePane()) { const value = shownNewTab(pane) if (!value) return false - setState("adding", (list) => list.filter((entry) => entry.id !== value.id)) + dismissNewTab(value.id) return true }, diff --git a/packages/app/src/runtime/plugin/side-region.tsx b/packages/app/src/runtime/plugin/side-region.tsx index 2c10a6d190b3..29bd1ad04d4c 100644 --- a/packages/app/src/runtime/plugin/side-region.tsx +++ b/packages/app/src/runtime/plugin/side-region.tsx @@ -383,23 +383,15 @@ export function PaneTabs(props: { - {/* After + runs a primary item, its tab owns the empty pane until it arrives. */} + {/* After + runs a primary item, its tab owns the empty pane until it arrives. The launchers list only in a + New tab, not in a pane with no tabs. */} - 0} - fallback={ -
-
- -
- {language.t("session.files.selectToOpen")} -
-
-
- } - > - -
+
+
+ +
{language.t("session.files.selectToOpen")}
+
+
@@ -518,7 +510,7 @@ function AddMenu(props: { items: readonly SessionPanelMenuItem[]; onChoose: () = ) } -/** The + menu items as rows, then the session's recently closed tabs, in the empty side region and the new tab view. */ +/** The + menu items as rows, then the session's recently closed tabs, in the new tab view. */ function Launchers(props: { items: readonly SessionPanelMenuItem[] onChoose: () => void