diff --git a/src/web-ui/src/app/scenes/browser/AGENTS.md b/src/web-ui/src/app/scenes/browser/AGENTS.md new file mode 100644 index 0000000000..5b1617b71d --- /dev/null +++ b/src/web-ui/src/app/scenes/browser/AGENTS.md @@ -0,0 +1,19 @@ +# Browser scene + +Native child-view visibility combines the controller window state, active panel, +toolbar transitions, and floating-surface occlusion. Keep native window event +subscriptions in the infrastructure adapter and share them across cached panels. +Do not use window focus alone as visibility: an unfocused window can remain visible. + +For preview scheduling and native visibility changes, run: + +```bash +pnpm --dir src/web-ui run test:run src/app/scenes/browser src/infrastructure/runtime/windowVisibility.test.ts +``` + +These tests cover event races, scheduling and DOM ownership. They do not establish +native rendering behavior. For minimize/restore changes, also use a real desktop +WebView: verify native minimized state, capture counts, child document visibility +and animation progress while minimized and after restoration. Check that inactive +or occluded panels remain hidden after restoring the window. Report the tested OS +and build profile; local fixtures do not establish remote-scenario behavior. diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx index 365a79bd62..c87e086448 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.test.tsx @@ -8,6 +8,15 @@ const mocks = vi.hoisted(() => ({ invoke: vi.fn<(...args: unknown[]) => Promise>(async () => {}), view: { label: 'test-browser', show: vi.fn(async () => {}), hide: vi.fn(async () => {}), close: vi.fn(async () => {}), setFocus: vi.fn(async () => {}) }, + hostVisible: true, + hostListeners: new Set<(visible: boolean) => void>(), +})); +vi.mock('@/infrastructure/runtime/windowVisibility', () => ({ + observeCurrentWindowVisibility: (changed: (visible: boolean) => void) => { + mocks.hostListeners.add(changed); + changed(mocks.hostVisible); + return () => mocks.hostListeners.delete(changed); + }, })); vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); vi.mock('@tauri-apps/api/webview', () => ({ Webview: { getByLabel: async () => mocks.view } })); @@ -25,6 +34,8 @@ let container: HTMLDivElement; let viewportBounds: DOMRect; beforeEach(() => { vi.clearAllMocks(); + mocks.hostVisible = true; + mocks.hostListeners.clear(); mocks.invoke.mockImplementation(async () => {}); vi.stubGlobal('__TAURI__', {}); vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); @@ -40,6 +51,7 @@ afterEach(async () => { document.body.replaceChildren(); vi.restoreAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); async function render(active = true) { @@ -55,6 +67,63 @@ function overlay() { return node; } +async function hostVisibility(visible: boolean) { + await mutate(() => { + mocks.hostVisible = visible; + mocks.hostListeners.forEach(changed => changed(visible)); + }); +} + +it('stops preview capture while minimized and resumes without changing the active interval', async () => { + vi.useFakeTimers(); + vi.stubGlobal('Image', class { src = ''; decode = async () => {}; }); + let frame = 0; + mocks.invoke.mockImplementation(async command => command === 'browser_webview_capture_preview' + ? { status: 'ready', dataUrl: `data:image/jpeg;base64,${frame++}` } : undefined); + const captures = () => mocks.invoke.mock.calls.filter(call => call[0] === 'browser_webview_capture_preview').length; + await render(); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(captures()).toBe(1); + await hostVisibility(false); + expect(mocks.view.hide).toHaveBeenCalledOnce(); + await act(async () => vi.advanceTimersByTimeAsync(30_000)); + expect(captures()).toBe(1); + await hostVisibility(true); + expect(mocks.view.show).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTimeAsync(1)); + expect(captures()).toBe(2); + await act(async () => vi.advanceTimersByTimeAsync(5000)); + expect(captures()).toBe(3); +}); + +it('restores only active, unoccluded views and does not steal focus on window restoration', async () => { + await render(); + await hostVisibility(false); + await render(false); + await hostVisibility(true); + expect(mocks.view.show).toHaveBeenCalledTimes(1); + const panel = overlay(); + await mutate(() => document.body.append(panel)); + await render(true); + await hostVisibility(false); + await hostVisibility(true); + expect(mocks.view.show).toHaveBeenCalledTimes(1); + mocks.view.setFocus.mockClear(); + await mutate(() => panel.remove()); + expect(mocks.view.show).toHaveBeenCalledTimes(2); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); +}); + +it('keeps a newly created view hidden when the host is already minimized', async () => { + mocks.hostVisible = false; + await render(); + expect(mocks.view.show).not.toHaveBeenCalled(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); + await hostVisibility(true); + expect(mocks.view.show).toHaveBeenCalledOnce(); + expect(mocks.view.setFocus).not.toHaveBeenCalled(); +}); + it('keeps creation, navigation, resize and activation hidden while any floating panel overlaps', async () => { const first = overlay(); const second = overlay(); diff --git a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts index 61c313547e..a3f8438348 100644 --- a/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts +++ b/src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts @@ -5,6 +5,7 @@ import { validateUrl } from './browserUrlCheck'; import { BrowserPreviewCache, prepareBrowserPreview, type BrowserPreviewResponse } from './browserPreviewCache'; import { alignBrowserViewport, type BrowserViewportBounds } from './browserViewportGeometry'; import { api } from '@/infrastructure/api/service-api/ApiClient'; +import { observeCurrentWindowVisibility } from '@/infrastructure/runtime/windowVisibility'; import { createNativeWebviewVisibility, hasNativeWebviewOccluder } from './nativeWebviewVisibility'; import { observeNativeWebviewOcclusion } from './nativeWebviewOcclusionObserver'; export { NATIVE_WEBVIEW_OCCLUSION_SELECTOR, rectanglesIntersect } from './nativeWebviewVisibility'; @@ -183,6 +184,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti const mountedRef = useRef(true); const toolbarSuspendedRef = useRef(false); const nativeVisibleRef = useRef(false); + const hostVisibleRef = useRef(false); const [previewUrl, setPreviewUrl] = useState(null); const [previewBounds, setPreviewBounds] = useState(null); const preview = useMemo(() => new BrowserPreviewCache({ @@ -193,7 +195,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti }), [log]); const visibility = useMemo(() => createNativeWebviewVisibility(target => { const viewport = viewportRef.current; - if (!mountedRef.current || !activeRef.current || toolbarSuspendedRef.current + if (!mountedRef.current || !hostVisibleRef.current || !activeRef.current || toolbarSuspendedRef.current || target !== webviewRef.current || !viewport?.isConnected) return false; const rect = viewport.getBoundingClientRect(); // Keep the last valid geometry during transient layout swaps, but still @@ -210,7 +212,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti }, (target, visible) => { if (target !== webviewRef.current) return; nativeVisibleRef.current = visible; - preview.setVisible(visible && document.visibilityState !== 'hidden'); + preview.setVisible(visible && hostVisibleRef.current); }), [preview]); const syncVisibility = useCallback(async (focus = false) => { @@ -501,12 +503,21 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti useEffect(() => { mountedRef.current = true; preview.resume(); - const handleDocumentVisibility = () => preview.setVisible(nativeVisibleRef.current && document.visibilityState !== 'hidden'); - document.addEventListener('visibilitychange', handleDocumentVisibility); + const stopObservingWindow = observeCurrentWindowVisibility(visible => { + hostVisibleRef.current = visible; + // Stop scheduling immediately; native hide may wait for a pending show. + if (!visible) preview.setVisible(false); + const handle = webviewRef.current; + if (handle) void visibility(handle).then(() => { + preview.setVisible(hostVisibleRef.current && nativeVisibleRef.current); + }).catch(syncError => { + log.warn('Update browser webview host visibility failed', syncError); + }); + }, windowError => log.warn('Read browser host window visibility failed', windowError)); return () => { mountedRef.current = false; preview.dispose(); - document.removeEventListener('visibilitychange', handleDocumentVisibility); + stopObservingWindow(); pageLoadUnlistenRef.current?.(); pageLoadUnlistenRef.current = null; if (resizeTimerRef.current !== null) { @@ -519,7 +530,7 @@ export function useEmbeddedBrowserWebview(options: UseEmbeddedBrowserWebviewOpti void visibility(handle).catch(() => {}).then(() => closeWebview(handle)); } }; - }, [closeWebview, preview, visibility]); + }, [closeWebview, log, preview, visibility]); useEffect(() => { if (!isTauri || !isVisible) return; diff --git a/src/web-ui/src/infrastructure/runtime/windowVisibility.test.ts b/src/web-ui/src/infrastructure/runtime/windowVisibility.test.ts new file mode 100644 index 0000000000..55440e9bbc --- /dev/null +++ b/src/web-ui/src/infrastructure/runtime/windowVisibility.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { observeCurrentWindowVisibility } from './windowVisibility'; + +const mocks = vi.hoisted(() => ({ + native: true, + isVisible: vi.fn(async () => true), + isMinimized: vi.fn(async () => false), + onResized: vi.fn(), + onFocusChanged: vi.fn(), +})); +vi.mock('./environment', () => ({ supportsNativeWindowControls: () => mocks.native })); +vi.mock('@tauri-apps/api/window', () => ({ getCurrentWindow: () => mocks })); +let resize: () => void; +let focus: () => void; +let cleanup: (() => void)[]; +let stopResize: ReturnType; +let stopFocus: ReturnType; +const error = vi.fn(); +beforeEach(() => { + vi.clearAllMocks(); + mocks.native = true; + mocks.isVisible.mockResolvedValue(true); + mocks.isMinimized.mockResolvedValue(false); + stopResize = vi.fn(); + stopFocus = vi.fn(); + mocks.onResized.mockImplementation(async callback => { resize = callback; return stopResize; }); + mocks.onFocusChanged.mockImplementation(async callback => { focus = callback; return stopFocus; }); + cleanup = []; +}); +afterEach(() => { cleanup.forEach(stop => stop()); vi.restoreAllMocks(); }); +function observe() { + const changed = vi.fn(); + cleanup.push(observeCurrentWindowVisibility(changed, error)); + return changed; +} +async function settle() { for (let step = 0; step < 30; step++) await Promise.resolve(); } + +it('pauses on native minimization even when the document remains visible and resumes on restore', async () => { + const changed = observe(); + expect(changed).toHaveBeenLastCalledWith(false); + await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(true)); + mocks.isMinimized.mockResolvedValue(true); + resize(); + await settle(); + expect(document.visibilityState).toBe('visible'); + expect(changed).toHaveBeenLastCalledWith(false); + mocks.isMinimized.mockResolvedValue(false); + resize(); + await settle(); + expect(changed).toHaveBeenLastCalledWith(true); + expect(changed).toHaveBeenCalledTimes(4); +}); + +it('shares subscriptions and native reads across cached panels without treating blur as hidden', async () => { + const first = observe(); + const second = observe(); + await vi.waitFor(() => expect(second).toHaveBeenLastCalledWith(true)); + expect(mocks.onResized).toHaveBeenCalledOnce(); + expect(mocks.onFocusChanged).toHaveBeenCalledOnce(); + expect(mocks.isMinimized).toHaveBeenCalledOnce(); + focus(); + await settle(); + expect(first).toHaveBeenCalledTimes(2); + cleanup[0](); + expect(stopResize).not.toHaveBeenCalled(); + cleanup[1](); + expect(stopResize).toHaveBeenCalledOnce(); + expect(stopFocus).toHaveBeenCalledOnce(); + cleanup = []; +}); + +it('discards stale snapshots and serializes a burst of native events', async () => { + const changed = observe(); + await vi.waitFor(() => expect(changed).toHaveBeenLastCalledWith(true)); + let finish!: (minimized: boolean) => void; + mocks.isMinimized.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + resize(); + resize(); + focus(); + expect(mocks.isMinimized).toHaveBeenCalledTimes(2); + finish(true); + await settle(); + expect(mocks.isMinimized).toHaveBeenCalledTimes(3); + expect(changed).toHaveBeenCalledTimes(2); +}); + +it('removes listeners that finish registering after the last subscriber leaves', async () => { + let finish!: (stop: () => void) => void; + mocks.onResized.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); + const changed = observe(); + await vi.waitFor(() => expect(mocks.onResized).toHaveBeenCalledOnce()); + cleanup[0](); + cleanup = []; + finish(stopResize); + await settle(); + expect(stopResize).toHaveBeenCalledOnce(); + expect(mocks.onFocusChanged).not.toHaveBeenCalled(); + expect(changed).toHaveBeenCalledTimes(1); +}); + +it('uses document visibility on web without invoking native APIs', async () => { + mocks.native = false; + const changed = observe(); + expect(changed).toHaveBeenLastCalledWith(true); + const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden'); + document.dispatchEvent(new Event('visibilitychange')); + expect(changed).toHaveBeenLastCalledWith(false); + visibility.mockReturnValue('visible'); + document.dispatchEvent(new Event('visibilitychange')); + expect(changed).toHaveBeenLastCalledWith(true); + expect(mocks.onResized).not.toHaveBeenCalled(); +}); + +it('keeps native hidden state through document visibility changes and recovers from read failure', async () => { + mocks.isMinimized.mockResolvedValue(true); + const changed = observe(); + await vi.waitFor(() => expect(mocks.isMinimized).toHaveBeenCalledOnce()); + await settle(); + document.dispatchEvent(new Event('visibilitychange')); + await settle(); + expect(changed).toHaveBeenCalledTimes(1); + mocks.isMinimized.mockRejectedValueOnce(new Error('Transient window query failure')); + resize(); + await settle(); + expect(error).toHaveBeenCalledOnce(); + expect(changed).toHaveBeenLastCalledWith(false); + mocks.isMinimized.mockResolvedValue(false); + focus(); + await settle(); + expect(changed).toHaveBeenLastCalledWith(true); +}); diff --git a/src/web-ui/src/infrastructure/runtime/windowVisibility.ts b/src/web-ui/src/infrastructure/runtime/windowVisibility.ts new file mode 100644 index 0000000000..b6475ca909 --- /dev/null +++ b/src/web-ui/src/infrastructure/runtime/windowVisibility.ts @@ -0,0 +1,104 @@ +import { supportsNativeWindowControls } from './environment'; + +type Subscriber = { changed: (visible: boolean) => void; error: (error: unknown) => void }; +type Observation = { visible: boolean; subscribers: Set; dispose: () => void }; +let observation: Observation | undefined; + +function startObservation(state: Observation): () => void { + let disposed = false; + let revision = 0; + let reading = false; + let refreshNative: (() => void) | undefined; + const unlisten: (() => void)[] = []; + const publish = (visible: boolean) => { + if (disposed || state.visible === visible) return; + state.visible = visible; + state.subscribers.forEach(subscriber => subscriber.changed(visible)); + }; + const reportError = (error: unknown) => { + if (!disposed) state.subscribers.forEach(subscriber => subscriber.error(error)); + }; + const documentVisible = () => document.visibilityState !== 'hidden'; + const onDocumentVisibility = () => { + if (!documentVisible()) publish(false); + if (refreshNative) refreshNative(); + else if (!supportsNativeWindowControls()) publish(documentVisible()); + }; + document.addEventListener('visibilitychange', onDocumentVisibility); + + if (supportsNativeWindowControls()) { + void (async () => { + // Window state belongs to this controller even when product commands use a peer. + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + if (disposed) return; + const nativeWindow = getCurrentWindow(); + refreshNative = () => { + revision++; + if (reading || disposed) return; + reading = true; + void (async () => { + try { + let readRevision: number; + do { + readRevision = revision; + try { + const [visible, minimized] = await Promise.all([ + nativeWindow.isVisible(), nativeWindow.isMinimized(), + ]); + // A newer resize/focus event invalidates an in-flight snapshot. + if (readRevision === revision) publish(visible && !minimized && documentVisible()); + } catch (error) { + reportError(error); + } + } while (!disposed && readRevision !== revision); + } finally { + reading = false; + } + })(); + }; + // Native resize events still arrive when WebView2 retains its DOM bounds + // and document.visibilityState stays 'visible' during minimization. + for (const register of [ + () => nativeWindow.onResized(() => refreshNative?.()), + () => nativeWindow.onFocusChanged(() => refreshNative?.()), + ]) { + const stop = await register(); + if (disposed) { stop(); return; } + unlisten.push(stop); + } + refreshNative(); + })().catch(reportError); + } + + return () => { + disposed = true; + document.removeEventListener('visibilitychange', onDocumentVisibility); + unlisten.forEach(stop => stop()); + }; +} + +/** One event-driven native subscription shared by all cached browser panels. No polling. */ +export function observeCurrentWindowVisibility( + changed: Subscriber['changed'], + error: Subscriber['error'], +): () => void { + const existing = observation; + const state = existing ?? { + // Keep native child views hidden until the initial host snapshot is known. + visible: !supportsNativeWindowControls() && document.visibilityState !== 'hidden', + subscribers: new Set(), + dispose: () => {}, + }; + observation = state; + const subscriber = { changed, error }; + state.subscribers.add(subscriber); + changed(state.visible); + if (!existing) state.dispose = startObservation(state); + return () => { + state.subscribers.delete(subscriber); + if (state.subscribers.size === 0) { + state.dispose(); + if (observation === state) observation = undefined; + } + }; +}