mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): show a still of the browser page while the side panel moves (#58185)
This commit is contained in:
committed by
GitHub
Unverified
parent
47a135540b
commit
c916bf2840
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user