Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/web-ui/src/app/scenes/browser/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@ const mocks = vi.hoisted(() => ({
invoke: vi.fn<(...args: unknown[]) => Promise<unknown>>(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 } }));
Expand All @@ -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);
Expand All @@ -40,6 +51,7 @@ afterEach(async () => {
document.body.replaceChildren();
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.useRealTimers();
});

async function render(active = true) {
Expand All @@ -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();
Expand Down
23 changes: 17 additions & 6 deletions src/web-ui/src/app/scenes/browser/useEmbeddedBrowserWebview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string | null>(null);
const [previewBounds, setPreviewBounds] = useState<BrowserViewportBounds | null>(null);
const preview = useMemo(() => new BrowserPreviewCache({
Expand All @@ -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
Expand All @@ -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) => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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;
Expand Down
132 changes: 132 additions & 0 deletions src/web-ui/src/infrastructure/runtime/windowVisibility.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn>;
let stopFocus: ReturnType<typeof vi.fn>;
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);
});
Loading
Loading