diff --git a/packages/app/component-tests/browser-pane.spec.ts b/packages/app/component-tests/browser-pane.spec.ts index 836ffe1d88f..98d8ddc2e3e 100644 --- a/packages/app/component-tests/browser-pane.spec.ts +++ b/packages/app/component-tests/browser-pane.spec.ts @@ -66,9 +66,12 @@ story("hides the native view immediately while the pane stays mounted", async ({ await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") }) -story("hides the native view from the first frame a panel moves until it lands", async ({ page }) => { +story("paints a still of the page in place of the native view while a panel moves", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") const motion = root.getByRole("button", { name: "Toggle panel motion", exact: true }) + const pane = root.getByRole("button", { name: "Toggle Review tab", exact: true }) + const native = root.getByTestId("native-Alpha") + const still = root.locator("#browser-panel img") // Read in the same task as the click: a native view left up for even one frame floats over the moving app. const visible = await motion.evaluate((element) => { @@ -78,14 +81,73 @@ story("hides the native view from the first frame a panel moves until it lands", }) expect(visible).toBe("false") + // A close: the page flies out as a still of itself rather than a blank box. + await expect(still).toBeVisible() + await pane.click() + await expect(still).toBeVisible() await motion.click() - await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true") + await expect(still).toHaveCount(0) + + // An open shows the still the close left, until the flight lands and the page is back. + await motion.click() + await pane.click() + await expect(still).toBeVisible() + await expect(native).toHaveAttribute("data-visible", "false") + await motion.click() + await expect(native).toHaveAttribute("data-visible", "true") + await expect(still).toHaveCount(0) +}) + +story("never flies a still of a page the tab has navigated away from", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const motion = root.getByRole("button", { name: "Toggle panel motion", exact: true }) + const native = root.getByTestId("native-Alpha") + const still = root.locator("#browser-panel img") + await motion.click() + await expect(still).toBeVisible() + await motion.click() + await expect(native).toHaveAttribute("data-visible", "true") + await expect(still).toHaveCount(0) + + await root.getByRole("button", { name: "Hold capture", exact: true }).click() + const address = root.getByRole("combobox", { name: "Browser address", exact: true }) + await address.fill("https://example.com/") + await address.press("Enter") + await expect(address).toHaveValue("https://example.com/") + await expect(native).toHaveAttribute("data-visible", "true") + await motion.click() + await expect(native).toHaveAttribute("data-visible", "false") + await expect(still).toHaveCount(0) + // The new page's own still stands in once it arrives. + await root.getByRole("button", { name: "Release capture", exact: true }).click() + await expect(still).toBeVisible() +}) + +story("refreshes page stills by the window's focus, not the document's", async ({ page }) => { + const root = page.getByTestId("browser-pane-fixture") + const captures = async () => Number((await root.getByText(/^Captures: \d+$/).textContent())?.split(": ")[1]) + await page.clock.install() + await expect(root.getByText(/^Captures: [1-9]/)).toBeVisible() + + // Main reports the window unfocused while this document keeps focus: stills follow the window, so they stop. + await root.getByRole("button", { name: "Blur window", exact: true }).click() + const paused = await captures() + await page.clock.runFor(120_000) + expect(await captures()).toBe(paused) + + // Focus coming back to the window, even straight into the embed, which never focuses this document, takes a fresh + // still at once. + await root.getByRole("button", { name: "Focus window", exact: true }).click() + await page.clock.runFor(10) + await expect(root.getByText(`Captures: ${paused + 1}`, { exact: true })).toBeVisible() }) story("keeps a still of the page under floating content that covers it", async ({ page }) => { const root = page.getByTestId("browser-pane-fixture") const still = root.locator("#browser-panel img") const native = root.getByTestId("native-Alpha") + // Reduced motion lands panels at once, so the pane keeps no flight still and captures only for the cover. + await page.emulateMedia({ reducedMotion: "reduce" }) 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() diff --git a/packages/app/src/runtime/extension/embeds.tsx b/packages/app/src/runtime/extension/embeds.tsx index 6b191f7eb49..441ae796f99 100644 --- a/packages/app/src/runtime/extension/embeds.tsx +++ b/packages/app/src/runtime/extension/embeds.tsx @@ -2,6 +2,7 @@ import { createEffect, on, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { createEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { prefersReducedMotion } from "@opencode/ui/motion" import type { EmbedProps, Embeds } from "@opencode/gui-extensions/sdk" import type { Bridge } from "@opencode/gui-extensions/sdk/bridge" @@ -16,6 +17,18 @@ type Input = { readonly moving: () => boolean } +// A decoded still of the page the embed showed, and where it sits in the box, in CSS pixels. +type Still = { + id: string + page: string | undefined + x: number + y: number + width: number + height: number + url: string + image: HTMLImageElement +} + /** Main-process embeds laid out over the DOM box an extension renders for them. */ export function createEmbeds(input: Input): Embeds { return { @@ -35,10 +48,15 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { const [store, setStore] = createStore<{ visible: boolean snapshot: { id: string; url: string; presented: boolean } | undefined + still: Still | undefined + flying: boolean }>({ visible: typeof document === "undefined" || document.visibilityState === "visible", // A still of the embed shown in the DOM while floating content covers the hidden native view. snapshot: undefined, + // The latest still of the shown page, which stands in for it while a panel flight hides the native view. + still: undefined, + flying: false, }) let element: HTMLDivElement | undefined @@ -49,6 +67,16 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { let release: ReturnType | undefined // The embed the last layout went to; it hides when another one takes the box. let placed: string | undefined + // Where the shown native view sits in the box, in CSS pixels; undefined while it is off screen. + let onscreen: Omit | undefined + let warming: ReturnType | undefined + let landing: ReturnType | undefined + let stilling: string | undefined + // The kept still's JPEG length, and how long to wait before the next still. + let bytes = 0 + let pause = 3000 + // Whether the window has focus, as main reports it: focus inside an embed blurs this document but not the window. + let focused = document.hasFocus() const canvas = document.createElement("canvas") canvas.width = canvas.height = 1 const paint = canvas.getContext("2d", { willReadFrequently: true }) @@ -68,6 +96,94 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { const hide = () => { if (placed) props.bridge.embed(placed) placed = undefined + onscreen = undefined + clearTimeout(warming) + } + + // A panel flight hides the native view, which no DOM box clips. Keep a recent still for it: one once the shown + // view's layout settles, then more while it stays on screen and the window has focus, never one per frame. A page + // that stops changing is captured less and less often. + const warm = (delay: number) => { + clearTimeout(warming) + // Reduced motion lands panels at once, so no flight needs a still. + warming = setTimeout(() => { + if (onscreen && !props.input.moving() && !prefersReducedMotion() && focused) take() + }, delay) + } + + const take = () => { + const placement = onscreen && { ...onscreen, page: props.page } + + if (!placement || stilling === placement.id) return + stilling = placement.id + void props.bridge + .capture(placement.id) + .catch(() => undefined) + .then(async (data) => { + // The same JPEG length as the still already kept: the page has most likely not changed. + const unchanged = !!data && store.still?.id === placement.id && data.length === bytes + bytes = data?.length ?? 0 + pause = unchanged ? Math.min(pause * 2, 60_000) : 3000 + + if (unchanged) { + if (stilling === placement.id) stilling = undefined + + if (onscreen?.id === placement.id) warm(pause) + + return + } + + const url = data ? URL.createObjectURL(new Blob([new Uint8Array(data)], { type: "image/jpeg" })) : undefined + const image = new Image() + + if (url) image.src = url + + const ready = + !!url && + (await image + .decode() + .then(() => true) + .catch(() => false)) + + // Another embed took the box, or the view was disposed, while this one captured. + const current = stilling === placement.id + + if (current) stilling = undefined + + if (!url || !ready || !current || props.id !== placement.id || props.page !== placement.page) { + if (url) URL.revokeObjectURL(url) + + if (current && onscreen?.id === placement.id) warm(pause) + + return + } + + const previous = store.still?.url + image.alt = "" + image.draggable = false + image.className = "absolute max-w-none pointer-events-none select-none" + Object.assign(image.style, { + left: `${placement.x}px`, + top: `${placement.y}px`, + width: `${placement.width}px`, + height: `${placement.height}px`, + }) + setStore("still", { ...placement, url, image }) + + if (previous) URL.revokeObjectURL(previous) + + if (onscreen?.id === placement.id) warm(pause) + }) + } + + const fly = (moving: boolean) => { + clearTimeout(landing) + + if (moving) return setStore("flying", true) + + // A panel that landed closed is gone; one that landed open keeps the still under its view until the view paints. + if (!props.visible) return setStore("flying", false) + landing = setTimeout(() => setStore("flying", false), 150) } // The native embed always paints above the DOM, so hide it while a floating @@ -185,6 +301,19 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { if (placed !== id) hide() placed = id + // The still lines up with the view's device-pixel-rounded bounds, so swapping one for the other moves nothing. + onscreen = visible + ? { + id, + x: left / zoom - rect.left, + y: top / zoom - rect.top, + width: Math.max(0, right - left) / zoom, + height: Math.max(0, bottom - top) / zoom, + } + : undefined + + if (onscreen) warm(600) + else clearTimeout(warming) const box = { visible, @@ -214,6 +343,32 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { if (frame === undefined) frame = requestAnimationFrame(tick) } + // A flight paints the still in the view's place. One that starts with the view on screen, like a close, first asks + // for a fresh still, before the effect below hides the view; one that opens the pane shows the last still. + createEffect( + on([props.input.moving, () => props.visible], ([moving]) => { + if (!moving) return fly(false) + + if (store.flying || (!onscreen && !props.visible)) return + take() + fly(true) + }), + ) + // A navigation or reload makes the kept still another page's. + createEffect( + on( + () => props.page, + () => { + if (store.still) URL.revokeObjectURL(store.still.url) + setStore("still", undefined) + bytes = 0 + pause = 3000 + + if (onscreen) warm(600) + }, + { defer: true }, + ), + ) createEffect( on( [ @@ -257,16 +412,37 @@ function EmbedView(props: EmbedProps & { input: Input; bridge: Bridge }) { onCleanup(() => appearance.disconnect()) createEventListener(window.matchMedia("(prefers-color-scheme: dark)"), "change", () => schedule(300)) createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible")) + + // Stills pause while the window is in the background; the page may have moved on since the last one. + const refocus = (next: boolean) => { + focused = next + + if (focused) warm(0) + } + + createEventListener(window, "focus", () => refocus(true)) + onCleanup( + props.bridge.on((message) => { + if (message.type === "focus") refocus(message.focused) + }), + ) onCleanup(() => { if (frame !== undefined) cancelAnimationFrame(frame) clearTimeout(release) + clearTimeout(landing) capturing = undefined + stilling = undefined replaceSnapshot() + + if (store.still) URL.revokeObjectURL(store.still.url) hide() }) return (
+ + {(still) =>
{still().image}
} +
{(url) => ( send(false)) } +export function wireFocus(win: BrowserWindow) { + const send = (focused: boolean) => { + if (win.isDestroyed() || win.webContents.isDestroyed()) return + emitIpcEvent(win.webContents, new WindowFocusChanged({ focused })) + } + + win.on("focus", () => send(true)) + win.on("blur", () => send(false)) +} + function iconsDir(path: Path.Path, paths: DesktopPaths.Resolved) { return app.isPackaged ? path.join(process.resourcesPath, "icons") : path.join(paths.developmentResourcesRoot, "icons") } diff --git a/packages/desktop/src/main/windows/index.ts b/packages/desktop/src/main/windows/index.ts index 97bc155792c..07378ee98dd 100644 --- a/packages/desktop/src/main/windows/index.ts +++ b/packages/desktop/src/main/windows/index.ts @@ -20,6 +20,7 @@ import { setZoomFactor, updateTitlebar, windowAppearance, + wireFocus, wireFullscreen, wireZoom, } from "./appearance" @@ -172,6 +173,7 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () { wireWindowRecovery(win, id, () => relaunchHandler()) register(win, id) wireFullscreen(win) + wireFocus(win) if (!early) loadWindow(win, "index.html") wireZoom(win) diff --git a/packages/desktop/src/renderer/extensions.ts b/packages/desktop/src/renderer/extensions.ts index 2393bb509c5..f8ed116e540 100644 --- a/packages/desktop/src/renderer/extensions.ts +++ b/packages/desktop/src/renderer/extensions.ts @@ -25,6 +25,7 @@ export function createExtensionBridge(): Bridge { dispatch({ type: "available", ipc: event.ipc, available: event.available }), ), listen("ExtensionsChanged", (event) => dispatch({ type: "extensions", list: event.list })), + listen("WindowFocusChanged", (event) => dispatch({ type: "focus", focused: event.focused })), listen("ExtensionMenubarItemsChanged", (event) => { menubar.revision++ dispatch({ type: "menubarItems", items: event.items }) diff --git a/packages/desktop/src/shared/ipc-rpc/events.ts b/packages/desktop/src/shared/ipc-rpc/events.ts index 2114695c3f9..a509663de25 100644 --- a/packages/desktop/src/shared/ipc-rpc/events.ts +++ b/packages/desktop/src/shared/ipc-rpc/events.ts @@ -14,6 +14,11 @@ export class WindowFullscreenChanged extends Schema.TaggedClass()("WindowFocusChanged", { + focused: Schema.Boolean, +}) {} + export class WindowPinchZoomChanged extends Schema.TaggedClass()("WindowPinchZoomChanged", { enabled: Schema.Boolean, }) {} @@ -60,6 +65,7 @@ export const DesktopEvent = Schema.Union([ DeepLinksOpened, MenuCommandTriggered, WindowFullscreenChanged, + WindowFocusChanged, WindowPinchZoomChanged, WindowZoomChanged, StorageChanged, diff --git a/packages/gui-extensions/src/browser/panel.fixture.tsx b/packages/gui-extensions/src/browser/panel.fixture.tsx index 93b55764a94..ec40b4bfd0a 100644 --- a/packages/gui-extensions/src/browser/panel.fixture.tsx +++ b/packages/gui-extensions/src/browser/panel.fixture.tsx @@ -174,7 +174,15 @@ export function mountBrowserPane(input: PaneHost) { const current = () => tabs.find((tab) => tab.title === store.session) ?? tabs[0] - const bridge: Pick = { + // Main's window focus reports, which focus moving into an embed does not end. + const windowFocus = new Set[0]>() + + const bridge: Pick = { + on: (listener) => { + windowFocus.add(listener) + + return () => windowFocus.delete(listener) + }, embed: (id, layout) => setStore("layouts", id, layout), capture: async () => { setStore("captures", (count) => count + 1) @@ -192,7 +200,7 @@ export function mountBrowserPane(input: PaneHost) { }, } - // SAFETY: the host embeds call only `embed` and `capture` on their bridge (`runtime/extension/embeds.tsx`). + // SAFETY: the host embeds call only `embed`, `capture` and `on` on their bridge (`runtime/extension/embeds.tsx`). const embeds = input.createEmbeds({ bridge: bridge as Bridge, zoom: () => 1, @@ -490,6 +498,13 @@ export function mountBrowserPane(input: PaneHost) { + + {(focused) => ( + + )} +