fix(app): keep new tab actions pane-local (#58452)

This commit is contained in:
usrnk1
2026-10-23 14:34:23 +02:00
committed by GitHub
Unverified
parent b7a2cfc138
commit 4572b5d054
6 changed files with 99 additions and 34 deletions
@@ -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.
+20 -21
View File
@@ -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<NewTab[]>([])
const [chosen, setChosen] = createSignal<string>()
const [state, setState] = createStore<{
adding: NewTab[]
chosen: Partial<Record<SessionPane, string>>
}>({ 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
},
@@ -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: {
</Show>
<Show when={props.placeholder && !current() && props.pane.active() === undefined}>
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
{/* 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. */}
<Show when={!primary()}>
<Show
when={launchers().length > 0}
@@ -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)
+1
View File
@@ -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, {
@@ -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. */