From 4572b5d0545bab12ddeb326b88eae3dedbbb12b9 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:34:23 +0200 Subject: [PATCH] fix(app): keep new tab actions pane-local (#58452) --- .../app/e2e/regression/side-panels.spec.ts | 65 ++++++++++++++++++- packages/app/src/runtime/extension/panels.tsx | 41 ++++++------ .../app/src/runtime/extension/side-region.tsx | 4 +- .../session/commands/use-session-commands.tsx | 16 +++-- packages/gui-extensions/README.md | 1 + packages/gui-extensions/src/sdk/registries.ts | 6 +- 6 files changed, 99 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/regression/side-panels.spec.ts b/packages/app/e2e/regression/side-panels.spec.ts index 9837d26a090..8e7291258cf 100644 --- a/packages/app/e2e/regression/side-panels.spec.ts +++ b/packages/app/e2e/regression/side-panels.spec.ts @@ -127,6 +127,69 @@ test("review content stays aligned with the side region in both directions", asy await expectTreeSentinel(page) }) +test("new tab selection and closing stay in the focused pane", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + const directory = "C:/OpenCode/PaneNewTabs" + const key = `local\u0000${base64Encode(directory)}/ses_panenewtabs` + + await openSession(page, { + name: "PaneNewTabs", + fileList: () => [], + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + seed: { + panes: { ses_panenewtabs: { review: true } }, + storage: { + "opencode.global.dat:layout": { + sessionTabs: { + [key]: { + all: ["file://README.md", "file://notes.md"], + active: "file://README.md", + panes: { "file://notes.md": "split" }, + selected: { side: "file://README.md", split: "file://notes.md" }, + }, + }, + }, + }, + }, + }) + + const side = page.locator('[data-pane-content="side"]') + const split = page.locator('[data-pane-content="split"]') + const mod = (await page.evaluate(() => /Mac|iPhone|iPad/.test(navigator.platform))) ? "Meta" : "Control" + const sideTabs = side.getByRole("tab", { name: "New tab", exact: true }) + const splitTabs = split.getByRole("tab", { name: "New tab", exact: true }) + await expect(side.getByRole("tab", { name: "README.md", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(split.getByRole("tab", { name: "notes.md", exact: true })).toHaveAttribute("aria-selected", "true") + + await side.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(sideTabs).toHaveCount(1) + await side.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(sideTabs).toHaveCount(2) + // Pick the older New tab, so a fallback to the newest one cannot pass unnoticed. + await sideTabs.nth(0).click() + await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") + + await split.getByRole("button", { name: "Add tab", exact: true }).click() + await expect(splitTabs).toHaveAttribute("aria-selected", "true") + await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") + await expect(sideTabs.nth(1)).toHaveAttribute("aria-selected", "false") + + await splitTabs.click() + await splitTabs.press(`${mod}+w`) + await expect(splitTabs).toHaveCount(0) + await expect(split.getByRole("tab", { name: "notes.md", exact: true })).toHaveAttribute("aria-selected", "true") + await expect(sideTabs).toHaveCount(2) + await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") + + // With focus on the region itself, close falls back to the active pane, not another pane's New tab. + await split.getByRole("tab", { name: "notes.md", exact: true }).click() + await page.locator("#review-panel").focus() + await page.keyboard.press(`${mod}+w`) + await expect(split).toHaveCount(0) + await expect(sideTabs).toHaveCount(2) + await expect(sideTabs.nth(0)).toHaveAttribute("aria-selected", "true") +}) + // 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")) @@ -202,7 +265,7 @@ test("the new tab view lists recently closed tabs, expands to the rest, and keep await readme.locator("..").getByRole("button", { name: "Close tab" }).click() await expect(toggle).toHaveAttribute("aria-expanded", "false") await toggle.click() - await expect(panel.getByRole("tab")).toHaveCount(0) + await expect(panel.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true") await expect(rows.first()).toContainText("README.md") // The list survives a reload, and reopening a row brings its tab back and takes it off the list. diff --git a/packages/app/src/runtime/extension/panels.tsx b/packages/app/src/runtime/extension/panels.tsx index f1f5d3ccb2c..c7c767ec314 100644 --- a/packages/app/src/runtime/extension/panels.tsx +++ b/packages/app/src/runtime/extension/panels.tsx @@ -2,7 +2,6 @@ import { batch, createEffect, createMemo, - createSignal, For, mergeProps, on, @@ -11,6 +10,7 @@ import { type Accessor, type JSX, } from "solid-js" +import { createStore } from "solid-js/store" import { Schema } from "effect" import { createMediaQuery } from "@solid-primitives/media" import { @@ -313,21 +313,24 @@ export function createRegion(input: { // and selects itself replaces it. A view stops being live once the selection moves off what it opened over. type NewTab = { id: string; pane: SessionPane; session: string; over: string | undefined } - const [adding, setAdding] = createSignal([]) - const [chosen, setChosen] = createSignal() + const [state, setState] = createStore<{ + adding: NewTab[] + chosen: Partial> + }>({ adding: [], chosen: {} }) + let serial = 0 const live = (value: NewTab) => value.session === input.view().key && value.over === panes[value.pane].active() - const newTabs = (pane: SessionPane) => adding().filter((value) => value.pane === pane && live(value)) + const newTabs = (pane: SessionPane) => state.adding.filter((value) => value.pane === pane && live(value)) const shownNewTab = (pane: SessionPane) => { const list = newTabs(pane) - return list.find((value) => value.id === chosen()) ?? list.at(-1) + return list.find((value) => value.id === state.chosen[pane]) ?? list.at(-1) } createEffect(() => { - if (adding().some((value) => !live(value))) setAdding((list) => list.filter(live)) + if (state.adding.some((value) => !live(value))) setState("adding", (list) => list.filter(live)) }) return { @@ -363,31 +366,27 @@ export function createRegion(input: { /** The new tab view a pane shows: the selected one, else the newest. */ shownNewTab, newTab: (pane: SessionPane) => shownNewTab(pane) !== undefined, - selectNewTab(id: string) { - setChosen(id) + selectNewTab(pane: SessionPane, id: string) { + setState("chosen", pane, id) }, /** Adds a new tab view in a pane, by default the one holding the selected tab, and shows it. */ openNewTab(pane: SessionPane = activePane()) { const id = `new:${++serial}` - setAdding((list) => [...list, { id, pane, session: input.view().key, over: panes[pane].active() }]) - setChosen(id) + batch(() => { + setState("adding", (list) => [...list, { id, pane, session: input.view().key, over: panes[pane].active() }]) + setState("chosen", pane, id) + }) }, dismissNewTab(id: string) { - setAdding((list) => list.filter((value) => value.id !== id)) + setState("adding", (list) => list.filter((value) => value.id !== id)) }, - /** Closes the shown new tab view, in one pane or wherever it is; returns whether one was showing. */ - closeNewTab(pane?: SessionPane) { - const panesToCheck: SessionPane[] = pane ? [pane] : ["side", "split", "bottom"] - - const value = panesToCheck.flatMap((id) => { - const shown = shownNewTab(id) - - return shown ? [shown] : [] - })[0] + /** 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 - setAdding((list) => list.filter((entry) => entry.id !== value.id)) + setState("adding", (list) => list.filter((entry) => entry.id !== value.id)) return true }, diff --git a/packages/app/src/runtime/extension/side-region.tsx b/packages/app/src/runtime/extension/side-region.tsx index b786dcc7328..df9fda7cc55 100644 --- a/packages/app/src/runtime/extension/side-region.tsx +++ b/packages/app/src/runtime/extension/side-region.tsx @@ -232,7 +232,7 @@ export function PaneTabs(props: { value={current()?.id ?? props.pane.active() ?? "empty"} onChange={(value) => { if (props.region.newTabs(props.pane.id).some((entry) => entry.id === value)) - return props.region.selectNewTab(value) + return props.region.selectNewTab(props.pane.id, value) // Kobalte selects the first tab while triggers register, including while the "+" menu's click is still // dispatching. Persist input events on a tab only; the region owns fallback selection. @@ -383,7 +383,7 @@ export function PaneTabs(props: { - {/* With a new tab item, the new tab it opens is on its way. */} + {/* After + runs a primary item, its tab owns the empty pane until it arrives. */} 0} diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index e3dafaa638d..3b3deb090f7 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -204,19 +204,21 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ) } + // In the pane holding focus; the region itself, or a key a native page handed over, means the selected tab's pane. + const focusedPane = () => { + const pane = document.activeElement?.closest("[data-pane-content]")?.getAttribute("data-pane-content") + + return pane === "side" || pane === "split" ? pane : undefined + } + const closeTab = () => { - if (actions.region.closeNewTab()) return + if (actions.region.closeNewTab(focusedPane())) return const tab = closableTab() if (tab) actions.region.close(tab) } - // In the pane holding focus; the region itself, or a key a native page handed over, means the selected tab's pane. - const openSideTab = () => { - const pane = document.activeElement?.closest("[data-pane-content]")?.getAttribute("data-pane-content") - - actions.region.openNewTab(pane === "side" || pane === "split" ? pane : undefined) - } + const openSideTab = () => actions.region.openNewTab(focusedPane()) const selectAdjacentSideTab = (offset: -1 | 1) => { const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset) diff --git a/packages/gui-extensions/README.md b/packages/gui-extensions/README.md index 1b238eaad98..45e28e097c5 100644 --- a/packages/gui-extensions/README.md +++ b/packages/gui-extensions/README.md @@ -136,6 +136,7 @@ flowchart LR - All panel callbacks take one named input: `list({ session, screen, open })`, `render({ tab, session, screen })`, `focus({ tab, session, screen, restored })`, `enter({ tab, session, screen })`, `close({ tab, session, screen })`, `reopen({ tab, session, screen })`, `normalize({ id, session, screen })`. Session fields are getters; the screen is the constant owning screen. Render tab fields are live getters too; lifecycle callbacks receive their event's tab. Stored ids and `restored` describe that call. Do not destructure live getters. Every `session.*` slot gets a non-null `screen`; `window.bottom` does not. - Render effects can run before public publication too: change-list views call `Changes.watch(input.screen, source)`, not `ctx.screen.current()`, so their first demand is never lost. - `fallback: true` opts a tab into selection when the stored selection is missing or cannot be selected; false or omission opts out. The host takes the first eligible regular tab, then a `first` tab, then a pinned tab, preserving order within each tier. Closing a tab still selects its neighbour. +- A `session.panel` menu item's `primary: true` makes the strip's + run it directly; the first primary item in `order` wins. It does not run automatically when an empty side region opens: the host shows its New tab view with the non-primary items that are not already open. `opened: true` hides an ordinary item from that launcher list, but not from the + menu. ```ts ctx.add(Panel, { diff --git a/packages/gui-extensions/src/sdk/registries.ts b/packages/gui-extensions/src/sdk/registries.ts index a73cc436981..520d4a773db 100644 --- a/packages/gui-extensions/src/sdk/registries.ts +++ b/packages/gui-extensions/src/sdk/registries.ts @@ -91,9 +91,9 @@ export interface SessionPanelMenuItem extends MenuItemBase { */ readonly opened?: boolean /** - * Marks the item that opens a new tab: + runs it directly instead of opening the menu, and the side region runs it - * when it opens with no tabs. The first primary item in `order` wins. Without one, + opens the menu and an empty side - * region lists the items. Omit it for an ordinary item. + * Makes + run this item directly instead of showing the host's launcher choices. The first primary item in `order` + * wins. Opening the side region with no tabs still shows the host's New tab view, which lists non-primary items + * that are not already open. Omit it for an ordinary launcher item. */ readonly primary?: true /** Runs the item. */