diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 647ce37da15..88bf9d83618 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -1191,6 +1191,26 @@ test("tab shortcuts cycle side-panel tabs when the side panel is focused with mu await expectPath(page, sessionHref(beta.id)) }) +test("⌘W past the side panel's last tab keeps the session tab", async ({ page }) => { + const sessions = await emptySidePanel(page) + await page.keyboard.press("ControlOrMeta+w") + + // From the composer ⌘W closes the session tab, so only a swallowed press above leaves Beta to land on. + await page.getByRole("textbox", { name: "Prompt", exact: true }).click() + await page.keyboard.press("ControlOrMeta+w") + await expectPath(page, sessionHref(sessions.beta.id)) + await expect(sessions.alphaTab).toHaveCount(0) +}) + +test("⌘W closes the session tab again once the window loses focus after the side panel empties", async ({ page }) => { + const sessions = await emptySidePanel(page) + // ⌘Tab away and back sends no click or key the page sees. + await page.evaluate(() => window.dispatchEvent(new Event("blur"))) + await page.keyboard.press("ControlOrMeta+w") + await expectPath(page, sessionHref(sessions.beta.id)) + await expect(sessions.alphaTab).toHaveCount(0) +}) + // Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform. test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => { await mockWorkspace(page, { name: "WindowsMenu", sessions: [] }) @@ -1279,3 +1299,37 @@ test("loads a tab's transcript while the pointer rests on it, and not when it pa await expect(message(b.id)).toBeVisible() expect(requested.filter((id) => id === b.id)).toHaveLength(1) }) + +// Closes Alpha's side tabs with ⌘W until the side panel empties and collapses, as an overshooting hand would. +async function emptySidePanel(page: Page) { + await page.setViewportSize({ width: 1440, height: 900 }) + const directory = "C:/OpenCode/SideCloseOvershoot" + const alpha = { id: "ses_side_close_alpha", title: "Side close alpha" } + const beta = { id: "ses_side_close_beta", title: "Side close beta" } + await openSession(page, { + name: "SideCloseOvershoot", + sessions: [alpha, beta], + fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))), + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + seed: { + panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } }, + storage: reviewTabs(directory, alpha.id), + }, + }) + + const panel = page.locator("#review-panel") + const reviewTab = panel.getByRole("tab", { name: "Review", exact: true }) + const fileTab = panel.getByRole("tab", { name: "README.md", exact: true }) + + await openFileLauncher(panel) + await panel.getByRole("treeitem", { name: "README.md", exact: true }).click() + await expect(fileTab).toHaveAttribute("aria-selected", "true") + await panel.getByText("contents:README.md", { exact: true }).click() + await page.keyboard.press("ControlOrMeta+w") + await expect(fileTab).toHaveCount(0) + await expect(reviewTab).toHaveAttribute("aria-selected", "true") + await page.keyboard.press("ControlOrMeta+w") + await expect(panel).toHaveAttribute("aria-hidden", "true") + + return { beta, alphaTab: page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(alpha.id)}"])`) } +} diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 3b3deb090f7..b040fad3519 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -1,3 +1,5 @@ +import { createEffect, createSignal, on } from "solid-js" +import { createEventListener } from "@solid-primitives/event-listener" import { useCommand, type CommandOption } from "@/shell/commands/command" import { adjacentTabKey } from "@/shell/titlebar/tab-order" import { useDialog } from "@opencode/ui/context/dialog" @@ -81,6 +83,27 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const inSide = actions.region.focus // ⌘T and ⌘W inside the side region open and close its tabs, unless the focused panel binds the key itself there. const sideKey = (event: KeyboardEvent) => inSide(event) && !extensionClaims(event) + // ⌘W on the side panel's last tab closes the panel and moves focus out of it, where one press too many would close + // the session tab. Until the user moves on, by clicking, focusing, another key, or another session, ⌘W closes nothing. + // The close moves focus synchronously, before this is set, so its own focusin does not clear it. + const [emptied, setEmptied] = createSignal(false) + const resetEmptied = () => setEmptied(false) + createEventListener(document, ["pointerdown", "focusin"], resetEmptied, { capture: true }) + createEventListener(window, "blur", resetEmptied) + createEventListener( + document, + "keydown", + (event) => { + if (["Meta", "Control", "Shift", "Alt"].includes(event.key)) return + if (!command.matches("file.close", event)) resetEmptied() + }, + { capture: true }, + ) + createEffect( + on(() => [actions.session.layout.tabKey(), actions.session.layout.view().side.opened()], resetEmptied, { + defer: true, + }), + ) const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1 const sideFocused = (target: EventTarget | null) => { @@ -404,15 +427,18 @@ export const useSessionCommands = (actions: SessionCommandContext) => { slash: "open", onSelect: openFile, }), - side && - fileCommand({ - id: "file.close", - title: language.t("command.tab.close"), - keybind: settings.keybinds.get("tab.close") ?? "mod+w", - hidden: !tab, - when: sideKey, - onSelect: closeTab, - }), + fileCommand({ + id: "file.close", + title: language.t("command.tab.close"), + keybind: settings.keybinds.get("tab.close") ?? "mod+w", + hidden: !tab, + when: (event) => (side ? sideKey(event) : emptied()), + onSelect: (source) => { + if (!side) return + closeTab() + setEmptied(source === "keybind" && !actions.session.layout.view().side.opened()) + }, + }), side && fileCommand({ id: "file.tab.new",