From 08ac1e168cc4dedbd39145abadf0cbf74dfe6fc1 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:50:03 +1000 Subject: [PATCH] fix(app): hide outgoing browser when switching sessions (#48243) --- .../component-tests/browser-pane.fixture.tsx | 103 ++++++++++++++++++ .../app/component-tests/browser-pane.spec.ts | 44 ++++++++ packages/app/src/session/browser/pane.tsx | 8 +- 3 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 packages/app/component-tests/browser-pane.fixture.tsx create mode 100644 packages/app/component-tests/browser-pane.spec.ts diff --git a/packages/app/component-tests/browser-pane.fixture.tsx b/packages/app/component-tests/browser-pane.fixture.tsx new file mode 100644 index 00000000000..95fccd07d52 --- /dev/null +++ b/packages/app/component-tests/browser-pane.fixture.tsx @@ -0,0 +1,103 @@ +import { DialogProvider } from "@opencode/ui/context/dialog" +import { Browser } from "@opencode/plugin-browser/rpc" +import { For, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { render } from "solid-js/web" +import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language" +import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane" +import type { createSessionBrowser } from "../src/session/browser/model" +import { SessionBrowserPane } from "../src/session/browser/pane" + +export function mountBrowserPane() { + const host = document.createElement("main") + host.dataset.testid = "browser-pane-fixture" + host.style.cssText = "position:fixed;inset:0;z-index:1000;background:#181818;color:#eee;padding:24px" + document.body.appendChild(host) + + function Fixture() { + const [store, setStore] = createStore({ + session: "Alpha", + mounted: true, + visible: true, + layouts: {} as Record, + }) + const tabs = ["Alpha", "Beta"].map((name) => ({ + id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`), + title: name, + url: `https://${name.toLowerCase()}.example/`, + loading: false, + canGoBack: false, + canGoForward: false, + generation: 0, + })) + // Record the native boundary per registration: hiding Beta cannot hide Alpha's view. + const registrations = new Map( + tabs.map((tab) => [ + tab.title, + { + setLayout: (layout) => setStore("layouts", tab.title, layout), + command: async () => undefined, + close: () => undefined, + }, + ]), + ) + const browser: ReturnType = { + available: () => true, + attached: () => !!registrations.get(store.session), + opened: () => !!registrations.get(store.session), + state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }), + tabs: () => tabs.filter((tab) => tab.title === store.session), + active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0], + registration: () => registrations.get(store.session), + error: () => undefined, + suspended: () => false, + close: () => undefined, + open: () => undefined, + command: () => undefined, + } + return ( + <> +

Browser pane lifecycle

+

Selected session: {store.session}

+ +
+ + + +
+

Native layout recorder

+

The desktop boundary keeps each session's page visible until its registration is hidden.

+ + {(tab) => ( +
+ {tab.title}: {store.layouts[tab.title]?.visible ? "visible" : "hidden"} +
+ )} +
+ + ) + } + + return render( + () => ( + + + + + + + + ), + host, + ) +} diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts new file mode 100644 index 00000000000..e574bc7af91 --- /dev/null +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -0,0 +1,44 @@ +import { fileURLToPath } from "node:url" +import { expect, story } from "../../storybook/playwright/story" + +const fixture = `/@fs/${fileURLToPath(new URL("./browser-pane.fixture.tsx", import.meta.url)).replaceAll("\\", "/")}` + +story.beforeEach(async ({ mount, page }) => { + const component = await mount("opencode-composer-flow--mixed-attachments") + await expect(component.getByRole("textbox", { name: "Prompt", exact: true })).toBeVisible() + await page.evaluate(async (fixture) => { + const { mountBrowserPane } = await import(fixture) + mountBrowserPane() + }, fixture) + await expect(page.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") +}) + +story("hides the previous registration when the mounted pane switches sessions", async ({ page }, testInfo) => { + const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("button", { name: "Beta", exact: true }).click() + await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true") + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") + await page.screenshot({ path: testInfo.outputPath("session-switch.png") }) + await root.getByRole("button", { name: "Alpha", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "false") +}) + +story("hides the outgoing browser when the destination has no browser pane", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("button", { name: "Empty", exact: true }).click() + await expect(root.locator("#browser-panel")).toHaveCount(0) + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") + await root.getByRole("button", { name: "Alpha", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") +}) + +story("hides and restores the same registration for Review tabs and unmount", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") + await root.getByRole("button", { name: "Toggle Review tab", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await root.getByRole("button", { name: "Unmount pane", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") +}) diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx index fbfdf03a44d..fd16dfca801 100644 --- a/packages/app/src/session/browser/pane.tsx +++ b/packages/app/src/session/browser/pane.tsx @@ -108,6 +108,13 @@ export function SessionBrowserPane(props: { browser: ReturnType !store.editing && setStore("address", address())) + createEffect( + on(registration, (current) => { + // Session routes can change before this pane unmounts. Hide the registration + // that owned the native view, rather than reading the destination's handle. + onCleanup(() => current?.setLayout()) + }), + ) createEffect( on( [ @@ -140,7 +147,6 @@ export function SessionBrowserPane(props: { browser: ReturnType setStore("visible", document.visibilityState === "visible")) onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) - registration()?.setLayout() }) return (