fix(app): stop an extra ⌘W closing the session after the side panel empties (#58497)

This commit is contained in:
Kit Langton
2026-10-23 10:15:11 -07:00
committed by GitHub
Unverified
parent 7d70f22f33
commit e4b15661b9
2 changed files with 89 additions and 9 deletions
+54
View File
@@ -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)}"])`) }
}
@@ -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",