fix(app): show a still of the browser page while the side panel moves (#58185)

This commit is contained in:
Kit Langton
2026-10-22 10:49:40 -07:00
committed by GitHub
Unverified
parent 47a135540b
commit c916bf2840
10 changed files with 295 additions and 5 deletions
@@ -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()
@@ -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<typeof setTimeout> | 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<Still, "page" | "url" | "image"> | undefined
let warming: ReturnType<typeof setTimeout> | undefined
let landing: ReturnType<typeof setTimeout> | 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 (
<div ref={element} class={props.class}>
<Show when={store.flying && store.still?.id === props.id && store.still?.page === props.page && store.still}>
{(still) => <div class="absolute inset-0 overflow-hidden pointer-events-none">{still().image}</div>}
</Show>
<Show when={store.snapshot?.id === props.id && props.visible && store.snapshot?.url}>
{(url) => (
<img
@@ -1,7 +1,12 @@
import { app, BrowserWindow, nativeImage, nativeTheme } from "electron"
import type { Path } from "effect"
import { type TitlebarTheme } from "../../shared/ipc-contract"
import { WindowFullscreenChanged, WindowPinchZoomChanged, WindowZoomChanged } from "../../shared/ipc-rpc/events"
import {
WindowFocusChanged,
WindowFullscreenChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
} from "../../shared/ipc-rpc/events"
import { emitIpcEvent } from "../ipc-events"
import type { DesktopPaths } from "../paths"
import { BACKGROUND_COLOR_KEY, PINCH_ZOOM_ENABLED_KEY } from "../storage/keys"
@@ -142,6 +147,16 @@ export function wireFullscreen(win: BrowserWindow) {
win.on("leave-full-screen", () => 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")
}
@@ -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)
@@ -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 })
@@ -14,6 +14,11 @@ export class WindowFullscreenChanged extends Schema.TaggedClass<WindowFullscreen
fullscreen: Schema.Boolean,
}) {}
// The BrowserWindow's own focus: focus moving into one of its embeds keeps it.
export class WindowFocusChanged extends Schema.TaggedClass<WindowFocusChanged>()("WindowFocusChanged", {
focused: Schema.Boolean,
}) {}
export class WindowPinchZoomChanged extends Schema.TaggedClass<WindowPinchZoomChanged>()("WindowPinchZoomChanged", {
enabled: Schema.Boolean,
}) {}
@@ -60,6 +65,7 @@ export const DesktopEvent = Schema.Union([
DeepLinksOpened,
MenuCommandTriggered,
WindowFullscreenChanged,
WindowFocusChanged,
WindowPinchZoomChanged,
WindowZoomChanged,
StorageChanged,
@@ -174,7 +174,15 @@ export function mountBrowserPane(input: PaneHost) {
const current = () => tabs.find((tab) => tab.title === store.session) ?? tabs[0]
const bridge: Pick<Bridge, "embed" | "capture"> = {
// Main's window focus reports, which focus moving into an embed does not end.
const windowFocus = new Set<Parameters<Bridge["on"]>[0]>()
const bridge: Pick<Bridge, "embed" | "capture" | "on"> = {
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) {
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
<button onClick={() => setStore("moving", (moving) => !moving)}>Toggle panel motion</button>
<For each={[false, true]}>
{(focused) => (
<button onClick={() => windowFocus.forEach((listener) => listener({ type: "focus", focused }))}>
{focused ? "Focus window" : "Blur window"}
</button>
)}
</For>
<button onClick={pick}>Pick element</button>
<button onClick={() => setStore("history", { visits })}>Seed history</button>
<button onClick={() => emit(store.session, { type: "page", tabID: current().id, icon, zoom: store.zoom })}>
@@ -876,6 +876,8 @@ export default function SessionBrowserPane(props: {
id={embed()}
visible={shown()}
frozen={commenting() || noting()}
// A navigation, in-page or not, or a reload starting is another page.
page={JSON.stringify([state()?.id, state()?.generation, state()?.url, !!state()?.loading])}
radius={10}
class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center"
>
@@ -124,6 +124,12 @@ export type BridgeMessage =
/** The new list. */
readonly list: readonly Installed[]
}
| {
/** The window gained or lost focus; focus inside one of its embeds counts as the window's. */
readonly type: "focus"
/** The window is focused now. */
readonly focused: boolean
}
| {
/** The native menu's extension items changed. */
readonly type: "menubarItems"
@@ -1144,6 +1144,11 @@ export interface EmbedProps {
readonly visible: boolean
/** Paints a still of the embed in place of the live view, so DOM content can float above it. */
readonly frozen?: boolean
/**
* What the embed shows, such as its URL. While a panel moves, the host paints a still of the embed in its place; a
* still taken while this had another value is dropped rather than shown.
*/
readonly page?: string
/** Radius of the bottom corners in CSS pixels. */
readonly radius?: number
/** CSS color the rounded corners show; defaults to the app backdrop behind the panel. */