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()
+ }, [])
+}