feat(app): cycle side-panel tabs when side panel is focused (#54899)

This commit is contained in:
Kit Langton
2026-10-13 13:13:34 -07:00
committed by GitHub
Unverified
parent 84e095526c
commit 573fd296d0
3 changed files with 132 additions and 2 deletions
+64
View File
@@ -533,6 +533,70 @@ test("each session tab shows its own file tab after a switch to a session in ano
}
})
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const directory = "C:/OpenCode/SidePanelTabs"
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
await openSession(page, {
name: "SidePanelTabs",
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 } } },
})
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 })
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
await panel.getByRole("button", { name: "Open file" }).click()
await panel.getByRole("button", { name: "README.md", exact: true }).click()
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
await composer.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expectPath(page, sessionHref(alpha.id))
await expect(fileTab).toHaveAttribute("aria-selected", "true")
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
await panel.getByText("contents:README.md", { exact: true }).click()
await page.keyboard.press("Control+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("Control+Shift+Tab")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await expectPath(page, sessionHref(alpha.id))
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
await page.keyboard.press("Control+Tab")
await expect(fileTab).toHaveAttribute("aria-selected", "true")
await page.keyboard.press("ControlOrMeta+w")
await expect(fileTab).toHaveCount(0)
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
await reviewTab.click()
await page.keyboard.press("Control+Tab")
await expectPath(page, sessionHref(beta.id))
})
// 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: [] })
@@ -1,4 +1,4 @@
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createEventListener } from "@solid-primitives/event-listener"
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
@@ -75,17 +75,30 @@ export function SideRegion(props: {
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
)
let root: HTMLElement | undefined
let tabList: HTMLDivElement | undefined
let selectionEvent: Event | undefined
createEffect(() => {
if (open()) return
const active = document.activeElement
if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return
active.blur()
})
return (
<Show when={isDesktop() && !!params.id}>
<aside
ref={root}
id="review-panel"
tabIndex={-1}
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
classList={{
"h-full shrink-0": !props.stacked,
"h-full min-h-0": props.stacked,
@@ -1,4 +1,5 @@
import { useCommand, type CommandOption } from "@/shell/commands/command"
import { adjacentTabKey } from "@/shell/titlebar/tab-order"
import { useDialog } from "@opencode/ui/context/dialog"
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
import { useFile, selectionFromLines, type FileSelection } from "@/workspaces/files/model"
@@ -80,11 +81,22 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
return entry && !entry.tab.pinned ? entry.key : undefined
}
const cycleSideTabs = () => actions.session.layout.view().side.opened() && actions.region.keys().length > 1
// Focus inside an extension command's scope belongs to that extension, which binds its own shortcuts there.
const extensionScoped = (target: EventTarget | null) =>
target instanceof Element &&
host.items(Command).some((item) => !!item.value.scope && !!target.closest(item.value.scope))
const sideFocused = (target: EventTarget | null) => {
const element =
target instanceof Element && target !== document.body && target !== document.documentElement
? target
: document.activeElement
return element instanceof Element && !!element.closest("#review-panel") && !extensionScoped(element)
}
const selectionPreview = (path: string, selection: FileSelection) => {
const content = file.get(path)?.content?.content
@@ -203,6 +215,28 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (tab) actions.region.close(tab)
}
const selectAdjacentSideTab = (offset: -1 | 1) => {
const key = adjacentTabKey(actions.region.keys(), actions.region.active(), offset)
if (!key) return
actions.region.select(key)
const panel = document.getElementById("review-panel")
if (!panel) return
const active = document.activeElement
if (
active instanceof HTMLElement &&
panel.contains(active) &&
!active.closest('[role="tab"][aria-selected="false"], [inert], .hidden')
)
return
;(panel.querySelector<HTMLElement>('[role="tab"][aria-selected="true"]') ?? panel).focus({ preventScroll: true })
}
const addSelection = () => {
const path = activeFile()
@@ -352,6 +386,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
const fileCmds = () => {
const tab = closableTab()
const cycle = cycleSideTabs()
return [
fileCommand({
@@ -370,6 +405,24 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
when: (event) => !extensionScoped(event.target),
onSelect: closeTab,
}),
cycle &&
fileCommand({
id: "file.tab.prev",
title: "",
keybind: settings.keybinds.get("tab.prev") ?? "mod+option+ArrowLeft,ctrl+shift+tab",
hidden: true,
when: (event) => sideFocused(event.target),
onSelect: () => selectAdjacentSideTab(-1),
}),
cycle &&
fileCommand({
id: "file.tab.next",
title: "",
keybind: settings.keybinds.get("tab.next") ?? "mod+option+ArrowRight,ctrl+tab",
hidden: true,
when: (event) => sideFocused(event.target),
onSelect: () => selectAdjacentSideTab(1),
}),
].filter((v) => !!v)
}