diff --git a/packages/app/component-tests/browser-pane.fixture.tsx b/packages/app/component-tests/browser-pane.fixture.tsx index b43787500f5..d52265cdca4 100644 --- a/packages/app/component-tests/browser-pane.fixture.tsx +++ b/packages/app/component-tests/browser-pane.fixture.tsx @@ -2,7 +2,7 @@ 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 { Portal, 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" @@ -27,7 +27,12 @@ export function mountBrowserPane() { loadErrors: {} as Record, error: undefined as string | undefined, layouts: {} as Record, + covered: false, + captures: 0, + holdCapture: false, }) + // Each capture waits until the fixture releases it, so a spec can observe the pending state. + const held: (() => void)[] = [] const tabs = ["Alpha", "Beta"].map((name) => ({ id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`), title: name, @@ -44,6 +49,17 @@ export function mountBrowserPane() { { setLayout: (layout) => setStore("layouts", tab.title, layout), command: async () => undefined, + capture: async () => { + setStore("captures", (count) => count + 1) + if (store.holdCapture) await new Promise((resolve) => held.push(resolve)) + const canvas = new OffscreenCanvas(4, 4) + const paint = canvas.getContext("2d") + if (paint) { + paint.fillStyle = "#3b82f6" + paint.fillRect(0, 0, 4, 4) + } + return canvas.convertToBlob() + }, close: () => undefined, }, ]), @@ -118,12 +134,34 @@ export function mountBrowserPane() { Complete navigation + + + -
+

Captures: {store.captures}

+
+ + {/* Floating content portals into like a menu or hover card over the page. */} + +
+ +

Native layout recorder

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

diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index 5779d492661..f8f434c57d9 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({ await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") }) +story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => { + const root = page.getByTestId("browser-pane-fixture") + const still = root.locator("#browser-panel img") + await root.getByRole("button", { name: "Hold capture", exact: true }).click() + await root.getByRole("button", { name: "Toggle popover", exact: true }).click() + await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible() + // The native page stays up until its still is ready, so the pane never shows blank. + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(still).toHaveCount(0) + + await root.getByRole("button", { name: "Release capture", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false") + await expect(still).toBeVisible() + await page.screenshot({ path: testInfo.outputPath("covered.png") }) + + await root.getByRole("button", { name: "Toggle popover", exact: true }).click() + await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(still).toHaveCount(0) + await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible() +}) + story("shows the empty state over a blank native page and restores navigation", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") await root.getByRole("button", { name: "Blank page", exact: true }).click() diff --git a/packages/app/src/runtime/platform/browser-pane.ts b/packages/app/src/runtime/platform/browser-pane.ts index 6b9fdfa1932..75505affbce 100644 --- a/packages/app/src/runtime/platform/browser-pane.ts +++ b/packages/app/src/runtime/platform/browser-pane.ts @@ -25,6 +25,8 @@ export type BrowserPaneEvent = export type BrowserPaneRegistration = { setLayout(layout?: BrowserPaneLayout): void command(command: BrowserPaneCommand): Promise + /** Captures the shown page, or resolves null when nothing is on screen. */ + capture(tabID: Browser.TabID): Promise close(): void } diff --git a/packages/app/src/session/browser/connection.test.ts b/packages/app/src/session/browser/connection.test.ts index 8238ee0b529..028be2e5b56 100644 --- a/packages/app/src/session/browser/connection.test.ts +++ b/packages/app/src/session/browser/connection.test.ts @@ -43,6 +43,9 @@ function fixture() { async command(command) { call.commands.push(command) }, + async capture() { + return null + }, close() { call.closed = true }, diff --git a/packages/app/src/session/browser/pane.tsx b/packages/app/src/session/browser/pane.tsx index 0eb67fe2277..f7f16f857c6 100644 --- a/packages/app/src/session/browser/pane.tsx +++ b/packages/app/src/session/browser/pane.tsx @@ -11,6 +11,7 @@ import { createStore } from "solid-js/store" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useCommand } from "@/shell/commands/command" +import type { Browser } from "@opencode/plugin-browser/rpc" import type { createSessionBrowser } from "./model" export function SessionBrowserPane(props: { browser: ReturnType; visible: boolean }) { @@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType