From 7a0520bbffd8a687e812812cac2e2a5ecec98810 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 19 Oct 2026 22:46:47 -0700 Subject: [PATCH] fix(app): focus the side panel when it opens (#57297) --- .../browser-pane-restore.spec.ts | 28 ++++++++++++++++ .../extension-host.fixture.tsx | 1 + packages/app/src/runtime/extension/panels.tsx | 33 +++++++++++++++++++ .../session/commands/use-session-commands.tsx | 2 +- .../session/header/session-header-actions.tsx | 4 +-- packages/app/src/session/screen.tsx | 3 +- packages/gui-extensions/README.md | 2 +- .../src/browser/panel.fixture.tsx | 13 +++++++- .../gui-extensions/src/browser/renderer.tsx | 6 ++++ packages/gui-extensions/src/sdk/registries.ts | 8 +++++ .../gui-extensions/src/terminal/renderer.tsx | 6 ++++ 11 files changed, 100 insertions(+), 6 deletions(-) diff --git a/packages/app/component-tests/browser-pane-restore.spec.ts b/packages/app/component-tests/browser-pane-restore.spec.ts index 87f1e4051e3..9adebceff13 100644 --- a/packages/app/component-tests/browser-pane-restore.spec.ts +++ b/packages/app/component-tests/browser-pane-restore.spec.ts @@ -256,6 +256,34 @@ story("keeps in-page shortcuts like ⌘K in the page while forwarding ⌘⇧R an await expect(root.getByTestId("session-state")).toHaveText("open") }) +story( + "reopening the side panel focuses a blank tab's address and closing it returns to the composer", + async ({ page }) => { + const root = page.getByTestId("browser-region-fixture") + const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform)) + const toggle = mac ? "Meta+Shift+r" : "Control+Shift+r" + const address = root.getByRole("combobox", { name: "Browser address", exact: true }) + const composer = root.getByRole("textbox", { name: "Composer", exact: true }) + const side = root.getByTestId("side-state") + + await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible() + await root.getByRole("button", { name: "Blank page", exact: true }).click() + await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true") + + await composer.focus() + await page.keyboard.press(toggle) + await expect(side).toHaveText("closed") + await page.keyboard.press(toggle) + await expect(side).toHaveText("open") + await expect(address).toBeFocused() + await page.keyboard.press(toggle) + await expect(side).toHaveText("closed") + await expect(composer).toBeFocused() + }, +) + story("opens a browser tab with ⌘T inside the pane and a session everywhere else", async ({ page }) => { const root = page.getByTestId("browser-region-fixture") const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform)) diff --git a/packages/app/component-tests/extension-host.fixture.tsx b/packages/app/component-tests/extension-host.fixture.tsx index dc6791fe2a9..d3882c38559 100644 --- a/packages/app/component-tests/extension-host.fixture.tsx +++ b/packages/app/component-tests/extension-host.fixture.tsx @@ -428,6 +428,7 @@ export function mountSessionRegion(input: { definitions: readonly Definition[]; remap() {}, }), closed: createClosedTabs(), + side: () => ({ opened: () => true, toggle() {} }), }) return ( diff --git a/packages/app/src/runtime/extension/panels.tsx b/packages/app/src/runtime/extension/panels.tsx index d4bf32c09a1..b5e5817ca55 100644 --- a/packages/app/src/runtime/extension/panels.tsx +++ b/packages/app/src/runtime/extension/panels.tsx @@ -113,9 +113,13 @@ export function createRegion(input: { screen: SessionScreen tabs: Tabs closed: ReturnType + /** Whether the side region is open, and the user's toggle for it. */ + side: Accessor<{ opened(): boolean; toggle(): void }> /** Runs when a close leaves no stored tab, in the same batch as the close. */ onEmptied?: () => void }) { + // The text field focus left when the user opened the side region, which a close from inside it returns to. + let returnFocus: HTMLElement | undefined const host = useExtensionHost() const stored = () => input.tabs().all() const providers = createMemo(() => host.items(Panel).filter((item) => item.value.region === input.region)) @@ -325,6 +329,35 @@ export function createRegion(input: { select(key: string) { input.tabs().setActive(key) }, + /** Opens or closes the side region for the user. Opening moves focus into it; closing gives focus back. */ + toggle() { + const side = input.side() + const focused = document.activeElement + + if (side.opened()) { + const inside = !!document.getElementById("review-panel")?.contains(focused) + const back = returnFocus + returnFocus = undefined + side.toggle() + + if (inside && back?.isConnected) back.focus({ preventScroll: true }) + + return + } + + returnFocus = + focused instanceof HTMLElement && (focused.isContentEditable || /^(INPUT|TEXTAREA)$/.test(focused.tagName)) + ? focused + : undefined + side.toggle() + const key = active() + const entry = key ? byKey().get(key) : undefined + + if (entry) entry.provider.enter?.(panelInput(input.view, input.screen, { tab: entry.tab })) + const root = document.getElementById("review-panel") + + if (root && !root.contains(document.activeElement)) root.focus({ preventScroll: true, focusVisible: false }) + }, close(key: string) { const entry = byKey().get(key) const last = stored().length === 1 && stored()[0] === key diff --git a/packages/app/src/session/commands/use-session-commands.tsx b/packages/app/src/session/commands/use-session-commands.tsx index 6d61dddacc6..559ff66dc4f 100644 --- a/packages/app/src/session/commands/use-session-commands.tsx +++ b/packages/app/src/session/commands/use-session-commands.tsx @@ -462,7 +462,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "review.toggle", title: language.t("command.review.toggle"), keybind: "mod+shift+r", - onSelect: () => actions.session.layout.view().side.toggle(), + onSelect: actions.region.toggle, }), ...(actions.region.pane("bottom").keys().length > 0 ? [ diff --git a/packages/app/src/session/header/session-header-actions.tsx b/packages/app/src/session/header/session-header-actions.tsx index d21c8e644f8..6b786859d43 100644 --- a/packages/app/src/session/header/session-header-actions.tsx +++ b/packages/app/src/session/header/session-header-actions.tsx @@ -7,7 +7,7 @@ import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSessionLayout } from "@/session/session-layout" -export function SessionReviewToggle() { +export function SessionReviewToggle(props: { onToggle: () => void }) { const command = useCommand() const language = useLanguage() const { view } = useSessionLayout() @@ -19,7 +19,7 @@ export function SessionReviewToggle() { reviewKeybind: command.keybindParts("review.toggle"), reviewVisible: true, reviewOpened: view().side.opened(), - onReviewToggle: () => view().side.toggle(), + onReviewToggle: props.onToggle, }} /> ) diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 8656e71e484..820eb924562 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -83,6 +83,7 @@ function SessionScreenContent(props: { screen: props.screen, tabs: session.layout.tabs, closed: attachment.closed, + side: () => session.layout.view().side, // Closing the last tab closes the side region, which opens with a new tab next time. onEmptied: () => { if (session.layout.view().side.opened()) session.layout.view().side.toggle() @@ -365,7 +366,7 @@ function SessionScreenContent(props: { onPointerDown={hideTimelineScrollbar} onClick={hideTimelineScrollbar} > - +
screen: SessionScreen tabs: Accessor + side: Accessor<{ opened(): boolean; toggle(): void }> }): { keys(): readonly string[] active(): string | undefined + toggle(): void entry(key: string): { readonly tab: PanelTab; readonly extension: string; readonly provider: Panel } | undefined close(key: string): void reopen(): void @@ -629,6 +631,8 @@ type RegionFixtureState = { closed: boolean /** How many times the review side panel toggled from a shortcut. */ reviews: number + /** Whether the side region is open. The pane stays mounted either way, so a page keeps its shortcuts. */ + opened: boolean /** How many times the shell's new session shortcut ran. */ sessions: number /** How many browser tabs the pane asked main to open. */ @@ -666,6 +670,7 @@ export function mountBrowserRegion(input: RegionHost) { away: false, closed: false, reviews: 0, + opened: true, sessions: 0, opens: 0, reopens: 0, @@ -960,6 +965,7 @@ export function mountBrowserRegion(input: RegionHost) { if (next.length !== all.length || next.some((key, index) => key !== all[index])) setAll(view().key, next) }, }), + side: () => ({ opened: () => store.opened, toggle: () => setStore("opened", (opened) => !opened) }), }) setSideRegion(() => region) @@ -1038,7 +1044,10 @@ export function mountBrowserRegion(input: RegionHost) { > setStore("closed", true)} - review={() => setStore("reviews", (count) => count + 1)} + review={() => { + setStore("reviews", (count) => count + 1) + sideRegion()?.toggle() + }} newSession={() => setStore("sessions", (count) => count + 1)} reopenSession={() => setStore("reopens", (count) => count + 1)} reopenSide={() => sideRegion()?.reopen()} @@ -1077,6 +1086,8 @@ export function mountBrowserRegion(input: RegionHost) {

{JSON.stringify(tree())}

{store.closed ? "closed" : "open"}

{store.reviews}

+

{store.opened ? "open" : "closed"}

+