diff --git a/desktop/src/components/browser/BrowserSurface.test.tsx b/desktop/src/components/browser/BrowserSurface.test.tsx index 828a548b..c92e0175 100644 --- a/desktop/src/components/browser/BrowserSurface.test.tsx +++ b/desktop/src/components/browser/BrowserSurface.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom' -import { fireEvent, render, screen } from '@testing-library/react' +import { act, fireEvent, render, screen } from '@testing-library/react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' beforeAll(() => { @@ -15,12 +15,14 @@ vi.mock('@tauri-apps/api/event', () => ({ listen: () => Promise.resolve(() => {} import { BrowserSurface } from './BrowserSurface' import { useBrowserPanelStore } from '../../stores/browserPanelStore' import { useWorkspacePanelStore } from '../../stores/workspacePanelStore' +import { useOverlayStore } from '../../stores/overlayStore' afterEach(() => { Object.values(bridge).forEach((f) => f.mockReset()) useBrowserPanelStore.setState(useBrowserPanelStore.getInitialState(), true) // browserPanelStore.open() now also opens the unified workbench; keep it isolated. useWorkspacePanelStore.setState(useWorkspacePanelStore.getInitialState(), true) + useOverlayStore.setState(useOverlayStore.getInitialState(), true) }) describe('BrowserSurface', () => { @@ -103,4 +105,37 @@ describe('BrowserSurface', () => { vi.useRealTimers() } }) + + it('hides the native webview when a fullscreen overlay opens, then re-shows it when the overlay closes', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + + // Initial mount: visibility-sync effect reveals the webview (count === 0). + expect(bridge.setVisible).toHaveBeenLastCalledWith(true) + + // Overlay opens → webview must hide. + act(() => { useOverlayStore.getState().push() }) + expect(bridge.setVisible).toHaveBeenLastCalledWith(false) + + // Overlay closes → webview must re-show (panel still mounted in browser mode). + act(() => { useOverlayStore.getState().pop() }) + expect(bridge.setVisible).toHaveBeenLastCalledWith(true) + }) + + it('keeps the native webview hidden while multiple overlays stack', () => { + useBrowserPanelStore.getState().open('s1', 'http://localhost:5173/') + render() + + act(() => { useOverlayStore.getState().push() }) + act(() => { useOverlayStore.getState().push() }) + expect(bridge.setVisible).toHaveBeenLastCalledWith(false) + + // Popping just one leaves count === 1 → still hidden. + act(() => { useOverlayStore.getState().pop() }) + expect(bridge.setVisible).toHaveBeenLastCalledWith(false) + + // Popping the last one → re-shown. + act(() => { useOverlayStore.getState().pop() }) + expect(bridge.setVisible).toHaveBeenLastCalledWith(true) + }) }) diff --git a/desktop/src/components/browser/BrowserSurface.tsx b/desktop/src/components/browser/BrowserSurface.tsx index 0ab181cb..71473801 100644 --- a/desktop/src/components/browser/BrowserSurface.tsx +++ b/desktop/src/components/browser/BrowserSurface.tsx @@ -5,11 +5,13 @@ import { computeWebviewBounds } from './computeWebviewBounds' import { previewBridge } from '../../lib/previewBridge' import { subscribePreviewEvents } from '../../lib/previewEvents' import { useBrowserPanelStore } from '../../stores/browserPanelStore' +import { useOverlayStore } from '../../stores/overlayStore' export function BrowserSurface({ sessionId }: { sessionId: string }) { const hostRef = useRef(null) const session = useBrowserPanelStore((s) => s.bySession[sessionId]) const store = useBrowserPanelStore.getState() + const overlayCount = useOverlayStore((s) => s.count) const reportBounds = () => { const el = hostRef.current @@ -21,10 +23,21 @@ export function BrowserSurface({ sessionId }: { sessionId: string }) { const el = hostRef.current if (!el || !session) return previewBridge.open(session.url, computeWebviewBounds(el.getBoundingClientRect())) - previewBridge.setVisible(true) + // The visibility-sync effect below owns setVisible() — including the + // initial reveal — so it always factors in overlayCount. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId]) + // Visibility-sync: a fullscreen DOM overlay (e.g. ImageGalleryModal) would + // otherwise be partially covered by the native child webview, which always + // renders above the DOM. While overlayCount > 0 we hide the webview; when + // it returns to 0 (and we're still mounted in browser mode) we re-show it. + // The Workbench-mode unmount teardown effect below still runs on unmount. + useEffect(() => { + if (!session) return + previewBridge.setVisible(overlayCount === 0) + }, [overlayCount, session]) + useEffect(() => { const el = hostRef.current if (!el) return diff --git a/desktop/src/components/chat/ImageGalleryModal.test.tsx b/desktop/src/components/chat/ImageGalleryModal.test.tsx new file mode 100644 index 00000000..a6ba9b60 --- /dev/null +++ b/desktop/src/components/chat/ImageGalleryModal.test.tsx @@ -0,0 +1,88 @@ +import '@testing-library/jest-dom' +import { render } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { ImageGalleryModal } from './ImageGalleryModal' +import { useOverlayStore } from '../../stores/overlayStore' + +const images = [{ src: 'data:image/png;base64,AAAA', name: 'a.png' }] + +const reset = () => { + useOverlayStore.setState(useOverlayStore.getInitialState(), true) +} + +beforeEach(reset) +afterEach(reset) + +describe('ImageGalleryModal · overlay suppression', () => { + it('increments overlay count while open and decrements on unmount', () => { + expect(useOverlayStore.getState().count).toBe(0) + + const { unmount } = render( + {}} + onSelect={() => {}} + />, + ) + expect(useOverlayStore.getState().count).toBe(1) + + unmount() + expect(useOverlayStore.getState().count).toBe(0) + }) + + it('does not increment when rendered with open=false', () => { + const { unmount } = render( + {}} + onSelect={() => {}} + />, + ) + expect(useOverlayStore.getState().count).toBe(0) + unmount() + expect(useOverlayStore.getState().count).toBe(0) + }) + + it('toggles count when open prop flips closed → open → closed', () => { + const { rerender, unmount } = render( + {}} + onSelect={() => {}} + />, + ) + expect(useOverlayStore.getState().count).toBe(0) + + rerender( + {}} + onSelect={() => {}} + />, + ) + expect(useOverlayStore.getState().count).toBe(1) + + rerender( + {}} + onSelect={() => {}} + />, + ) + expect(useOverlayStore.getState().count).toBe(0) + + unmount() + expect(useOverlayStore.getState().count).toBe(0) + }) +}) diff --git a/desktop/src/components/chat/ImageGalleryModal.tsx b/desktop/src/components/chat/ImageGalleryModal.tsx index 670ead73..e89cba88 100644 --- a/desktop/src/components/chat/ImageGalleryModal.tsx +++ b/desktop/src/components/chat/ImageGalleryModal.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react' import { Modal } from '../shared/Modal' +import { useOverlayStore } from '../../stores/overlayStore' type GalleryImage = { src: string @@ -17,6 +18,16 @@ type Props = { export function ImageGalleryModal({ open, images, activeIndex, onClose, onSelect }: Props) { const activeImage = images[activeIndex] + // Native child webviews (e.g. the in-app browser preview) always render + // ABOVE the DOM, so this fullscreen overlay would be partially covered. + // Bump the overlay count while open so BrowserSurface can hide the webview. + useEffect(() => { + if (!open) return + const { push, pop } = useOverlayStore.getState() + push() + return () => pop() + }, [open]) + useEffect(() => { if (!open || images.length <= 1) return const handleKeyDown = (event: KeyboardEvent) => { diff --git a/desktop/src/stores/overlayStore.test.ts b/desktop/src/stores/overlayStore.test.ts new file mode 100644 index 00000000..1f120227 --- /dev/null +++ b/desktop/src/stores/overlayStore.test.ts @@ -0,0 +1,47 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useOverlayStore } from './overlayStore' + +const reset = () => { + useOverlayStore.setState(useOverlayStore.getInitialState(), true) +} + +describe('overlayStore', () => { + beforeEach(reset) + + it('starts at count 0', () => { + expect(useOverlayStore.getState().count).toBe(0) + }) + + it('push increments by 1', () => { + useOverlayStore.getState().push() + expect(useOverlayStore.getState().count).toBe(1) + useOverlayStore.getState().push() + expect(useOverlayStore.getState().count).toBe(2) + }) + + it('pop decrements by 1', () => { + const { push, pop } = useOverlayStore.getState() + push() + push() + pop() + expect(useOverlayStore.getState().count).toBe(1) + }) + + it('pop at 0 stays clamped at 0', () => { + useOverlayStore.getState().pop() + expect(useOverlayStore.getState().count).toBe(0) + useOverlayStore.getState().pop() + expect(useOverlayStore.getState().count).toBe(0) + }) + + it('balances pushes and pops back to 0', () => { + const { push, pop } = useOverlayStore.getState() + push(); push(); push() + expect(useOverlayStore.getState().count).toBe(3) + pop(); pop(); pop() + expect(useOverlayStore.getState().count).toBe(0) + // extra pop is still clamped + pop() + expect(useOverlayStore.getState().count).toBe(0) + }) +}) diff --git a/desktop/src/stores/overlayStore.ts b/desktop/src/stores/overlayStore.ts new file mode 100644 index 00000000..e8a92cbc --- /dev/null +++ b/desktop/src/stores/overlayStore.ts @@ -0,0 +1,36 @@ +import { useEffect } from 'react' +import { create } from 'zustand' + +/** + * Tracks how many fullscreen DOM overlays (image preview modals, etc.) are + * currently mounted. A native child webview (e.g. the in-app browser preview) + * always renders ABOVE the DOM, so it covers any fullscreen overlay; surfaces + * driving such webviews read this count and hide the webview while count > 0. + * + * Reusable: any fullscreen overlay can opt in via `push()` / `pop()` (or the + * helper hook `useSuppressBrowserOverlay()` below). + */ +type OverlayStore = { + count: number + push: () => void + pop: () => void +} + +export const useOverlayStore = create((set) => ({ + count: 0, + push: () => set((state) => ({ count: state.count + 1 })), + pop: () => set((state) => ({ count: Math.max(0, state.count - 1) })), +})) + +/** + * Mount-scoped helper: increments the overlay count on mount, decrements on + * unmount. Pairs cleanly with strict-mode double-invoke because each effect + * run does exactly one inc + one dec. + */ +export function useSuppressBrowserOverlay() { + useEffect(() => { + const { push, pop } = useOverlayStore.getState() + push() + return () => pop() + }, []) +}