diff --git a/.github/workflows/desktop-e2e.yml b/.github/workflows/desktop-e2e.yml index 3ea5df89904..f11fc3b11de 100644 --- a/.github/workflows/desktop-e2e.yml +++ b/.github/workflows/desktop-e2e.yml @@ -10,6 +10,7 @@ on: - '.github/workflows/desktop-e2e.yml' - '.github/workflows/desktop-release.yml' - 'apps/desktop/**' + - 'apps/sim/app/workspace/**/browser-session/**' - 'apps/sim/app/_styles/**' - 'apps/sim/lib/postcss/**' - 'apps/sim/postcss.config.mjs' diff --git a/apps/desktop/e2e/browser-chrome.spec.ts b/apps/desktop/e2e/browser-chrome.spec.ts new file mode 100644 index 00000000000..a6917acf763 --- /dev/null +++ b/apps/desktop/e2e/browser-chrome.spec.ts @@ -0,0 +1,238 @@ +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { _electron as electron, expect, test } from '@playwright/test' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { build } from 'esbuild' +import postcss from 'postcss' +import loadPostcssConfig from 'postcss-load-config' + +const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url)) +const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('./fixtures/browser-chrome.tsx', import.meta.url)) +const SCOPE = 'browser-chrome-fixture' + +test('crowded tabs and renderer overlays work with a real native browser page', async () => { + const testInfo = test.info() + let server: Server | undefined + let userData: string | undefined + let app: Awaited> | undefined + try { + const config = await loadPostcssConfig({}, SIM_DIR) + const stylesheet = join(SIM_DIR, 'app/_styles/globals.css') + const css = await postcss(config.plugins).process( + `${readFileSync(stylesheet, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`, + { from: stylesheet } + ) + const hook = join( + SIM_DIR, + 'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts' + ) + const bundle = await build({ + stdin: { + contents: `import { mountBrowserChromeFixture } from ${JSON.stringify(FIXTURE)}; +import { useBrowserPanelOcclusion } from ${JSON.stringify(hook)}; +mountBrowserChromeFixture(useBrowserPanelOcclusion);`, + resolveDir: SIM_DIR, + loader: 'tsx', + }, + bundle: true, + write: false, + outfile: testInfo.outputPath('fixture.js'), + external: ['node:async_hooks'], + banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' }, + format: 'iife', + platform: 'browser', + tsconfig: join(SIM_DIR, 'tsconfig.json'), + define: { 'process.env.NODE_ENV': '"development"' }, + }) + server = createServer((request, response) => { + const path = new URL(request.url ?? '/', 'http://localhost').pathname + if (path === '/fixture.js' || path === '/fixture.css') { + response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css') + response.end( + path.endsWith('.js') + ? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text + : css.css + ) + return + } + if (path.startsWith('/api/')) { + response.setHeader('Content-Type', 'application/json') + response.end( + path === '/api/auth/get-session' + ? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } }) + : '{}' + ) + return + } + response.writeHead(200, { + 'Content-Type': 'text/html', + 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/', + }) + response.end( + path === '/page' + ? '

Browser fixture

A live page behind the application chrome.

' + : '
' + ) + }) + await new Promise((resolve) => server?.listen(0, resolve)) + const address = server.address() + if (!address || typeof address === 'string') throw new Error('Missing fixture address') + userData = mkdtempSync(join(tmpdir(), 'sim-browser-chrome-e2e-')) + app = await electron.launch({ + args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'], + cwd: DESKTOP_DIR, + env: { + ...process.env, + SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`, + SIM_DESKTOP_USER_DATA: userData, + }, + }) + const shell = app + const page = await shell.firstWindow() + await shell.evaluate(({ app, BrowserWindow }) => { + const window = BrowserWindow.getAllWindows()[0] + window.setContentSize(1100, 750) + window.webContents.setBackgroundThrottling(false) + app.focus({ steal: true }) + window.focus() + }) + const nativeVisible = () => + shell.evaluate(({ BrowserWindow, WebContentsView }) => + BrowserWindow.getAllWindows()[0] + .contentView.children.find( + (view) => view instanceof WebContentsView && view.webContents.getURL().endsWith('/page') + ) + ?.getVisible() + ) + await expect(page.locator('[data-tab-strip-item]')).toHaveCount(8) + await test.step('Eight tabs shrink to available width without early overflow', async () => { + const geometry = await page.locator('[data-tab-strip-item]').evaluateAll((tabs) => + tabs.map((tab) => { + const bounds = tab.getBoundingClientRect() + return { width: bounds.width, right: bounds.right } + }) + ) + expect(geometry.every((tab) => tab.width >= 64 && tab.width < 160)).toBe(true) + expect(geometry.at(-1)?.right).toBeLessThan(1070) + await page.screenshot({ path: testInfo.outputPath('tabs.png') }) + }) + await test.step('Short labels keep their compact intrinsic width', async () => { + await page.locator('#short-tabs').click() + const widths = await page + .locator('[data-tab-strip-item]') + .evaluateAll((tabs) => tabs.map((tab) => tab.getBoundingClientRect().width)) + expect(widths.every((width) => width >= 64 && width < 96)).toBe(true) + await page.locator('#eight-tabs').click() + }) + await test.step('Crowded tabs preserve controls and scroll', async () => { + await page.locator('#many-tabs').click() + await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18) + const overflow = await page + .locator('[data-tab-strip-item]') + .first() + .evaluate((tab) => ({ + width: tab.getBoundingClientRect().width, + scrollWidth: tab.parentElement?.scrollWidth ?? 0, + clientWidth: tab.parentElement?.clientWidth ?? 0, + })) + expect(overflow.width).toBeGreaterThanOrEqual(64) + expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth) + await page.locator('#eight-tabs').click() + }) + await test.step('An open menu recovers when no native page was available for its initial capture', async () => { + await page.locator('#menu-trigger').click() + await expect(page.getByRole('menu')).toBeVisible() + expect( + await page.evaluate( + (scope) => + ( + globalThis as typeof globalThis & { simDesktop: SimDesktopApi } + ).simDesktop.browserAgent.capturePanelSnapshot(scope), + SCOPE + ) + ).toBeNull() + await expect(page.locator('#snapshot')).toHaveCount(0) + await page + .locator('#start-native') + .evaluate((button) => (button as HTMLButtonElement).click()) + await expect.poll(nativeVisible).toBe(false) + await expect(page.locator('#snapshot')).toBeVisible() + await expect(page.getByRole('menu')).toBeVisible() + await page.keyboard.press('Escape') + await expect.poll(nativeVisible).toBe(true) + }) + await test.step('Tooltip appears above the real native browser', async () => { + await page.locator('#reference').hover() + await expect(page.getByRole('tooltip')).toBeVisible() + await expect.poll(nativeVisible).toBe(false) + await expect(page.locator('#snapshot')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('tooltip.png') }) + }) + await test.step('Leaving the tooltip restores the native browser', async () => { + await page.mouse.move(200, 180) + await expect.poll(nativeVisible).toBe(true) + await expect(page.locator('#snapshot')).toHaveCount(0) + }) + await test.step('An open menu remains above the page when its item tooltip disappears', async () => { + await page.locator('#menu-trigger').click() + await expect(page.getByRole('menu')).toBeVisible() + await expect.poll(nativeVisible).toBe(false) + await page.locator('#tooltip-item').hover() + await expect(page.getByRole('tooltip')).toBeVisible() + await page.locator('#plain-item').hover() + await expect(page.getByRole('tooltip')).toHaveCount(0) + await expect(page.getByRole('menu')).toBeVisible() + await expect.poll(nativeVisible).toBe(false) + await expect(page.locator('#snapshot')).toBeVisible() + }) + await test.step('Resizing with an open menu refreshes the captured viewport', async () => { + await shell.evaluate(({ BrowserWindow }) => + BrowserWindow.getAllWindows()[0].setContentSize(1000, 750) + ) + await expect + .poll(() => + page.locator('#snapshot').evaluate((image) => image.getBoundingClientRect().width) + ) + .toBe(450) + await expect.poll(nativeVisible).toBe(false) + await page.screenshot({ path: testInfo.outputPath('menu.png') }) + }) + await test.step('Escape dismisses the menu and restores the native browser', async () => { + await page.keyboard.press('Escape') + await expect(page.getByRole('menu')).toHaveCount(0) + await expect.poll(nativeVisible).toBe(true) + await expect(page.locator('#snapshot')).toHaveCount(0) + }) + } catch (error) { + const page = app?.windows()[0] + if (page && !page.isClosed()) { + await page.screenshot({ path: testInfo.outputPath('failure.png') }) + const overlays = await page.locator('[data-native-surface-overlay]').evaluateAll((elements) => + elements.map((element) => ({ + text: element.textContent, + bounds: element.getBoundingClientRect().toJSON(), + opacity: getComputedStyle(element).opacity, + visibility: getComputedStyle(element).visibility, + })) + ) + await testInfo.attach('overlay-state', { + body: JSON.stringify(overlays, null, 2), + contentType: 'application/json', + }) + } + throw error + } finally { + await app?.close() + if (server?.listening) { + const listener = server + await new Promise((resolve, reject) => + listener.close((error) => (error ? reject(error) : resolve())) + ) + } + if (userData) rmSync(userData, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/e2e/fixtures/browser-chrome.tsx b/apps/desktop/e2e/fixtures/browser-chrome.tsx new file mode 100644 index 00000000000..1e1ae4e5e53 --- /dev/null +++ b/apps/desktop/e2e/fixtures/browser-chrome.tsx @@ -0,0 +1,178 @@ +import { useEffect, useRef, useState } from 'react' +import type { BrowserPanelSnapshot } from '@sim/browser-protocol' +import type { SimDesktopApi } from '@sim/desktop-bridge' +import { + Button, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + TabStrip, + Tooltip, +} from '@sim/emcn' +import { File } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { createPortal } from 'react-dom' +import { createRoot } from 'react-dom/client' + +const SCOPE = 'browser-chrome-fixture' + +interface BrowserChromeFixtureProps { + useOcclusion: ( + scopeId: string, + activeTabId: string | null, + visible: boolean, + getHostRect: () => DOMRect | null + ) => { + snapshot: BrowserPanelSnapshot | null + snapshotLayer: 'modal' | 'popover' + onSnapshotError: () => void + } +} + +function reportPanelBounds(api: SimDesktopApi, host: HTMLDivElement | null) { + const bounds = host?.getBoundingClientRect() + if (!bounds) return + api.browserAgent.setPanelBounds( + { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }, + null, + SCOPE + ) +} + +function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) { + const host = useRef(null) + const [activeTabId, setActiveTabId] = useState(null) + const [selected, setSelected] = useState('tab-0') + const [tabCount, setTabCount] = useState(8) + const [shortTitles, setShortTitles] = useState(false) + const [error, setError] = useState(null) + const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop + const { snapshot, snapshotLayer, onSnapshotError } = useOcclusion( + SCOPE, + activeTabId, + true, + () => host.current?.getBoundingClientRect() ?? null + ) + + const startBrowser = async () => { + if (!api.browserAgent.openUrl) throw new Error('Native browser bridge is unavailable') + await api.browserAgent.activateScope(SCOPE) + const tabs = await api.browserAgent.openUrl( + `${location.origin.replace('127.0.0.1', 'localhost')}/page`, + SCOPE + ) + setActiveTabId(tabs.activeTabId) + reportPanelBounds(api, host.current) + } + + useEffect(() => { + const element = host.current + if (!element) return + const observer = new ResizeObserver(() => reportPanelBounds(api, element)) + observer.observe(element) + const heartbeat = window.setInterval(() => reportPanelBounds(api, element), 1_000) + return () => { + window.clearInterval(heartbeat) + observer.disconnect() + api.browserAgent.setPanelBounds(null, null, SCOPE) + } + }, [api]) + + return ( + <> +
+ + + + +
+ {error &&

{error}

} + ({ + id: `tab-${index}`, + title: shortTitles ? 'A' : `Example resource ${index + 1} with a descriptive title`, + icon: , + active: selected === `tab-${index}`, + }))} + variant='floating' + onSelect={setSelected} + onClose={() => undefined} + onNew={() => setTabCount((count) => count + 1)} + /> +
+ + + + + + A descriptive reference tooltip that extends across the browser boundary. + + +
+
+ + + + + + Open example item + + + Preview details + + Additional details for this example menu item. + + + +
+
+ {snapshot && + createPortal( + , + document.body + )} + + ) +} + +/** The real Sim hook is supplied by the bundle entry using Sim's module aliases. */ +export function mountBrowserChromeFixture(useOcclusion: BrowserChromeFixtureProps['useOcclusion']) { + const root = document.getElementById('root') + if (!root) throw new Error('Missing fixture root') + createRoot(root).render() +} diff --git a/apps/desktop/src/main/browser-agent/panel.ts b/apps/desktop/src/main/browser-agent/panel.ts index 242e41378a6..b72290245da 100644 --- a/apps/desktop/src/main/browser-agent/panel.ts +++ b/apps/desktop/src/main/browser-agent/panel.ts @@ -649,12 +649,7 @@ export async function capturePanelSnapshot( ownerWindow?: BrowserWindow, scopeId = activePanelScopeId ): Promise { - if ( - !scopeId || - !panelUpdateAllowed(ownerWindow, scopeId) || - panelBounds === null || - panelOccluded - ) { + if (!scopeId || !panelUpdateAllowed(ownerWindow, scopeId) || panelBounds === null) { return null } const active = host.activeTab() @@ -725,7 +720,8 @@ export async function capturePanelSnapshot( const generation = ++panelCaptureGeneration let capture: ReturnType try { - capture = contents.capturePage(undefined, { stayHidden: false }) + /** Refresh an occluded frame without exposing the native view above renderer overlays. */ + capture = contents.capturePage(undefined, { stayHidden: panelOccluded }) } catch (error) { logger.warn('Could not capture browser panel for a toolbar menu', { error: getErrorMessage(error, 'unknown'), diff --git a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx index cc03a9ae5b5..ac5fb04e59a 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-platform-loop/hero-resource-panel.tsx @@ -27,7 +27,6 @@ import { TablesRecordsTable } from '@/app/(landing)/tables/components/tables-rec import { RESOURCE_HEADER_CLASSES, RESOURCE_TAB_ICON_CLASS, - resourceTabWidthClass, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' export type HeroResourceId = 'workflow' | 'table' | 'brief' @@ -155,7 +154,7 @@ export function HeroResourcePanel({ onSelect={(id) => onActiveChange(id as HeroResourceId)} onClose={(id) => onCloseResource(id as HeroResourceId)} variant='floating' - className={cn(RESOURCE_HEADER_CLASSES.stripGeometry, resourceTabWidthClass(tabs.length))} + className={RESOURCE_HEADER_CLASSES.stripGeometry} newTabControl={ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts index e98cbe483db..dc6c75c5682 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.test.ts @@ -53,7 +53,10 @@ let activeContainer: HTMLDivElement | null = null let nextAnimationFrameId = 1 const animationFrames = new Map() -function renderOcclusionHook(panelVisible = true): HookHarness { +function renderOcclusionHook( + panelVisible = true, + getHostRect = () => new DOMRect(500, 64, 800, 700) +): HookHarness { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') document.body.appendChild(container) @@ -63,7 +66,7 @@ function renderOcclusionHook(panelVisible = true): HookHarness { let latest: OcclusionResult | undefined function Probe({ visible }: { visible: boolean }) { - latest = useBrowserPanelOcclusion('chat-1', 'tab-1', visible) + latest = useBrowserPanelOcclusion('chat-1', 'tab-1', visible, getHostRect) return null } @@ -293,6 +296,140 @@ describe('useBrowserPanelOcclusion modal lifecycle', () => { expect(hook.result().snapshot).toBeNull() hook.unmount() }) + + it('replaces the native page only while a moving tooltip overlaps it', async () => { + let nativeVisible = true + setBrowserPanelOccluded.mockImplementation(async (hidden: boolean) => { + nativeVisible = !hidden + return true + }) + const hook = renderOcclusionHook() + const tooltip = document.createElement('div') + tooltip.setAttribute('data-native-surface-overlay', '') + let bounds = new DOMRect(100, 100, 200, 60) + tooltip.getBoundingClientRect = () => bounds + act(() => document.body.appendChild(tooltip)) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + + bounds = new DOMRect(450, 100, 200, 60) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(false) + expect(hook.result().snapshotLayer).toBe('popover') + + bounds = new DOMRect(100, 100, 200, 60) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + hook.unmount() + }) + + it('retains overlapping menus through modal handoff and releases after the last overlay', async () => { + let nativeVisible = true + setBrowserPanelOccluded.mockImplementation(async (hidden: boolean) => { + nativeVisible = !hidden + return true + }) + const hook = renderOcclusionHook() + const menu = document.createElement('div') + menu.setAttribute('data-native-surface-overlay', '') + menu.getBoundingClientRect = () => new DOMRect(450, 100, 200, 60) + act(() => document.body.appendChild(menu)) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(false) + + const modal = addModalOverlay() + await flushOcclusionLifecycle() + expect(hook.result().snapshotLayer).toBe('modal') + act(() => modal.remove()) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(false) + expect(hook.result().snapshotLayer).toBe('popover') + + act(() => menu.remove()) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + hook.unmount() + }) + + it('keeps an open menu above the native page while a resized frame is still capturing', async () => { + let nativeVisible = true + setBrowserPanelOccluded.mockImplementation(async (hidden: boolean) => { + nativeVisible = !hidden + return true + }) + let bounds = new DOMRect(500, 64, 800, 700) + const hook = renderOcclusionHook(true, () => bounds) + const menu = document.createElement('div') + menu.setAttribute('data-native-surface-overlay', '') + menu.getBoundingClientRect = () => new DOMRect(450, 100, 200, 60) + act(() => document.body.appendChild(menu)) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(false) + + let finishCapture: ((frame: typeof SNAPSHOT) => void) | undefined + captureBrowserPanelSnapshot.mockImplementation( + () => + new Promise((resolve) => { + finishCapture = resolve + }) + ) + bounds = new DOMRect(500, 64, 600, 700) + await flushOcclusionLifecycle() + expect(finishCapture).toBeTypeOf('function') + expect(nativeVisible).toBe(false) + expect(hook.result().snapshot).toEqual(SNAPSHOT) + + const resized = { ...SNAPSHOT, viewportBounds: { ...SNAPSHOT.viewportBounds, width: 600 } } + finishCapture?.(resized) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(false) + expect(hook.result().snapshot).toEqual(resized) + + bounds = new DOMRect(500, 64, 400, 700) + await flushOcclusionLifecycle() + act(() => menu.remove()) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + + finishCapture?.({ ...resized, viewportBounds: { ...resized.viewportBounds, width: 400 } }) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + }) + + it('does not hide the page when an overlapping tooltip disappears during capture', async () => { + let finishCapture: ((frame: typeof SNAPSHOT) => void) | undefined + captureBrowserPanelSnapshot.mockImplementation( + () => + new Promise((resolve) => { + finishCapture = resolve + }) + ) + let nativeVisible = true + setBrowserPanelOccluded.mockImplementation(async (hidden: boolean) => { + nativeVisible = !hidden + return true + }) + const hook = renderOcclusionHook() + const tooltip = document.createElement('div') + tooltip.setAttribute('data-native-surface-overlay', '') + tooltip.getBoundingClientRect = () => new DOMRect(450, 100, 200, 60) + act(() => document.body.appendChild(tooltip)) + await flushOcclusionLifecycle() + expect(finishCapture).toBeTypeOf('function') + + act(() => tooltip.remove()) + await flushOcclusionLifecycle() + finishCapture?.(SNAPSHOT) + await flushOcclusionLifecycle() + expect(nativeVisible).toBe(true) + expect(hook.result().snapshot).toBeNull() + hook.unmount() + }) }) describe('snapshotMatchesHost', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts index 913c7e63f4a..bbe07551d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts @@ -13,6 +13,7 @@ import { NATIVE_SURFACE_OCCLUSION_PREPARE_EVENT, type NativeSurfaceOcclusionPrepareDetail, } from '@sim/emcn' +import { backoffWithJitter } from '@sim/utils/retry' import { captureBrowserPanelSnapshot, setBrowserPanelOccluded, @@ -21,10 +22,15 @@ import { const SNAPSHOT_DECODE_TIMEOUT_MS = 3_000 const SNAPSHOT_PAINT_TIMEOUT_MS = 1_000 +const OVERLAY_RETRY_LIMIT = 3 /** Full-screen modal/takeover effects that must composite above the native page. */ export const NATIVE_SURFACE_OCCLUSION_SELECTOR = '[data-native-surface-occlusion]' +/** Passive notifications must never suspend interaction with the browser page. */ +const NATIVE_SURFACE_OVERLAY_SELECTOR = + '[data-native-surface-overlay]:not([data-native-surface-overlay="passive"])' + export type BrowserPanelSnapshotLayer = 'modal' | 'popover' export type BrowserPanelOverlay = 'downloads' | 'resources' | 'suggestions' | 'toolbar' @@ -118,30 +124,134 @@ interface BrowserPanelOcclusion extends BrowserPanelOverlayController { snapshot: BrowserPanelSnapshot | null snapshotLayer: BrowserPanelSnapshotLayer onSnapshotError: () => void + /** Allows the bounds reporter to release its modal lease without revealing through another overlay. */ + shouldKeepNativeHidden: () => boolean } /** * Deliberately narrower than `data-native-surface-overlay`: the broad marker is - * also used by menus, tooltips, and toasts, none of which should blur the whole - * browser panel or take ownership of its native-surface lease. + * also used by transient menus/tooltips and passive notifications. Transient + * overlays only need a replacement when they overlap the page; notifications + * never acquire a lease that could suspend browser interaction indefinitely. */ export function hasNativeSurfaceOcclusion(root: ParentNode = document): boolean { return root.querySelector(NATIVE_SURFACE_OCCLUSION_SELECTOR) !== null } -function nodeContainsNativeSurfaceOcclusion(node: Node): boolean { - if (node instanceof Element && node.matches(NATIVE_SURFACE_OCCLUSION_SELECTOR)) return true +function nodeContainsSelector(node: Node, selector: string): boolean { + if (node instanceof Element && node.matches(selector)) return true return ( (node instanceof Element || node instanceof DocumentFragment) && - node.querySelector(NATIVE_SURFACE_OCCLUSION_SELECTOR) !== null + node.querySelector(selector) !== null ) } export function mutationsTouchNativeSurfaceOcclusion(records: MutationRecord[]): boolean { return records.some((record) => { if (record.type === 'attributes') return true - return [...record.addedNodes, ...record.removedNodes].some(nodeContainsNativeSurfaceOcclusion) + return [...record.addedNodes, ...record.removedNodes].some((node) => + nodeContainsSelector(node, NATIVE_SURFACE_OCCLUSION_SELECTOR) + ) + }) +} + +/** + * Watches marked overlays only while any exist. Sampling their painted bounds + * also catches cursor-following tooltips and CSS transitions that do not resize + * the element. DOM churn elsewhere (including streamed chat text) does not + * rescan the document or start an animation-frame loop. + */ +function observeOverlappingOverlays( + getHostRect: () => DOMRect | null, + onChange: (overlapping: boolean) => Promise +): () => void { + let overlays: HTMLElement[] = [] + let frame: number | null = null + let overlapping = false + let hostGeometry = '' + let disposed = false + let revision = 0 + let retryTimer: ReturnType | undefined + + const reconcile = (attempt = 0) => { + clearTimeout(retryTimer) + const request = ++revision + void onChange(overlapping).then((ready) => { + if (disposed || request !== revision || ready || attempt >= OVERLAY_RETRY_LIMIT) return + retryTimer = setTimeout( + () => reconcile(attempt + 1), + backoffWithJitter(attempt + 1, null, { baseMs: 250, maxMs: 1_000 }) + ) + }) + } + + const measure = () => { + frame = null + const host = getHostRect() + const geometry = host ? `${host.x}:${host.y}:${host.width}:${host.height}` : '' + const next = Boolean( + host && + host.width > 0 && + host.height > 0 && + overlays.some((overlay) => { + const bounds = overlay.getBoundingClientRect() + if ( + bounds.width <= 0 || + bounds.height <= 0 || + bounds.right <= host.left || + bounds.left >= host.right || + bounds.bottom <= host.top || + bounds.top >= host.bottom + ) + return false + const style = getComputedStyle(overlay) + return ( + style.visibility !== 'hidden' && + style.visibility !== 'collapse' && + style.display !== 'none' && + style.opacity !== '0' && + (overlay.checkVisibility?.({ opacityProperty: true, visibilityProperty: true }) ?? true) + ) + }) + ) + if (next !== overlapping || (next && geometry !== hostGeometry)) { + overlapping = next + reconcile() + } + hostGeometry = geometry + if (overlays.length > 0) frame = requestAnimationFrame(measure) + } + + const refresh = () => { + overlays = Array.from(document.querySelectorAll(NATIVE_SURFACE_OVERLAY_SELECTOR)) + if (frame === null) frame = requestAnimationFrame(measure) + } + const observer = new MutationObserver((records) => { + if ( + records.some( + (record) => + record.type === 'attributes' || + [...record.addedNodes, ...record.removedNodes].some((node) => + nodeContainsSelector(node, NATIVE_SURFACE_OVERLAY_SELECTOR) + ) + ) + ) + refresh() + }) + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ['data-native-surface-overlay'], }) + refresh() + return () => { + disposed = true + revision++ + clearTimeout(retryTimer) + observer.disconnect() + if (frame !== null) cancelAnimationFrame(frame) + } } /** @@ -172,17 +282,6 @@ async function decodeSnapshot(dataUrl: string): Promise { } } -/** - * Coordinates the renderer replacement for the native browser surface. - * - * Browser chrome popovers use a replacement immediately below `--z-popover`, - * where opening them is pixel-neutral. Full-screen modals use the same exact - * replacement below `--z-modal`, allowing the real modal scrim to tint and - * backdrop-blur it exactly like the rest of Sim. Both are one shared lease: - * changing layers never reveals or recaptures the native view, and the view is - * revealed only after the final reason disappears. - */ - /** Largest tolerated drift, in CSS px, between a capture and the live host rect. */ const SNAPSHOT_GEOMETRY_TOLERANCE_PX = 1 @@ -205,6 +304,15 @@ export function snapshotMatchesHost( ) } +/** + * Coordinates the renderer replacement for the native browser surface. + * + * Transient overlays use a replacement below the shared dropdown layer, + * where opening them is pixel-neutral. Full-screen modals use the same exact + * replacement below `--z-modal`, allowing the real modal scrim to tint and + * backdrop-blur it exactly like the rest of Sim. Both share a lease and reuse + * a frame while its tab and geometry remain valid. + */ export function useBrowserPanelOcclusion( scopeId: string, activeTabId: string | null, @@ -220,10 +328,12 @@ export function useBrowserPanelOcclusion( const activeTabIdRef = useRef(activeTabId) const panelVisibleRef = useRef(panelVisible) const screenOcclusionPresentRef = useRef(false) + const overlappingOverlayPresentRef = useRef(false) const nativeHiddenRef = useRef(false) const transitionVersionRef = useRef(0) const paintIdRef = useRef(0) const pendingPaintRef = useRef(null) + const cancelPreparationRef = useRef<(() => void) | null>(null) const paintFramesRef = useRef([]) const reconcileChainRef = useRef>(Promise.resolve(true)) const mountedRef = useRef(true) @@ -293,10 +403,20 @@ export function useBrowserPanelOcclusion( const desiredLayer = useCallback((): BrowserPanelSnapshotLayer | null => { if (!panelVisibleRef.current) return null if (screenOcclusionPresentRef.current) return 'modal' - if (pendingOverlayRef.current || activeOverlayRef.current) return 'popover' + if ( + pendingOverlayRef.current || + activeOverlayRef.current || + overlappingOverlayPresentRef.current + ) + return 'popover' return null }, []) + const shouldKeepNativeHidden = useCallback( + () => nativeHiddenRef.current && desiredLayer() !== null, + [desiredLayer] + ) + const reconcile = useCallback( async (version: number): Promise => { if (!mountedRef.current || version !== transitionVersionRef.current) return false @@ -318,27 +438,41 @@ export function useBrowserPanelOcclusion( return true } - // Modal and popover reasons share the captured frame. Moving between the - // two is only a stacking-level change; revealing here would punch the - // native WebContentsView through the modal for a frame. + /** Reuse valid frames across layer changes to avoid revealing through an overlay. */ if (nativeHiddenRef.current) { - if (snapshotRenderRef.current) updateSnapshotLayer(desired) - return true + const frame = snapshotRenderRef.current?.frame + if ( + desired === 'modal' || + (frame && + (!activeTabIdRef.current || frame.tabId === activeTabIdRef.current) && + snapshotMatchesHost(frame, getHostRectRef.current?.() ?? null)) + ) { + if (frame) updateSnapshotLayer(desired) + return true + } } // Modal scroll locking can alter panel geometry between capture and the // final native hide. One fresh capture retries that now-settled layout. - const maxAttempts = desired === 'modal' ? 3 : 1 + const maxAttempts = desired === 'modal' ? 3 : 2 for (let attempt = 0; attempt < maxAttempts; attempt++) { - const frame = await captureBrowserPanelSnapshot(scopeId).catch(() => null) + const cancelled = new Promise((resolve) => { + cancelPreparationRef.current = () => resolve(null) + }) + const frame = await Promise.race([ + captureBrowserPanelSnapshot(scopeId).catch(() => null), + cancelled, + ]) if (!mountedRef.current || version !== transitionVersionRef.current) return false desired = desiredLayer() if (!desired) return false if (!frame || (activeTabIdRef.current && frame.tabId !== activeTabIdRef.current)) { + cancelPreparationRef.current = null continue } - const decoded = await decodeSnapshot(frame.dataUrl) + const decoded = await Promise.race([decodeSnapshot(frame.dataUrl), cancelled]) + cancelPreparationRef.current = null if (!mountedRef.current || version !== transitionVersionRef.current) return false desired = desiredLayer() if (!desired || !decoded) continue @@ -391,9 +525,8 @@ export function useBrowserPanelOcclusion( } if (version !== transitionVersionRef.current) return false - // Keep the last painted replacement available while a modal retries. - // Ordinary popovers need a pixel-exact swap or their native fallback. - if (desired !== 'modal') updateSnapshotRender(null) + /** Retain the replacement during modal retries or while the native page remains hidden. */ + if (desired !== 'modal' && !nativeHiddenRef.current) updateSnapshotRender(null) desired = desiredLayer() if (!desired) return false } @@ -431,15 +564,24 @@ export function useBrowserPanelOcclusion( [desiredLayer, scopeId, settlePaint, updateSnapshotLayer, updateSnapshotRender] ) - const scheduleReconcile = useCallback((): Promise => { + const scheduleReconcile = useCallback(async (): Promise => { const version = ++transitionVersionRef.current + cancelPreparationRef.current?.() + cancelPreparationRef.current = null cancelPendingPaint() const run = reconcileChainRef.current.then( () => reconcile(version), () => reconcile(version) ) reconcileChainRef.current = run - return run + let current = run + let ready = await current + /** A newer request can service the same overlay while superseding its original transition. */ + while (!ready && mountedRef.current && current !== reconcileChainRef.current) { + current = reconcileChainRef.current + ready = await current + } + return ready }, [cancelPendingPaint, reconcile]) const clearBrowserOverlay = useCallback(() => { @@ -455,14 +597,7 @@ export function useBrowserPanelOcclusion( if (!panelVisibleRef.current) return Promise.resolve(true) screenOcclusionPresentRef.current = true clearBrowserOverlay() - const scheduled = scheduleReconcile() - return scheduled.then((ready) => { - // Two modal layers can mount in one React commit. The second request - // supersedes the first transition version; its gate must not make the - // first modal visible merely because that canceled transition resolved. - const latest = reconcileChainRef.current - return !ready && latest !== scheduled ? latest : ready - }) + return scheduleReconcile() }, [clearBrowserOverlay, scheduleReconcile]) const closeOverlay = useCallback( @@ -589,16 +724,35 @@ export function useBrowserPanelOcclusion( } }, [clearBrowserOverlay, panelVisible, prepareScreenOcclusion, scheduleReconcile]) + useEffect(() => { + if (!panelVisible || !supportsAtomicBrowserPanelOcclusion()) return + const stop = observeOverlappingOverlays( + () => getHostRectRef.current?.() ?? null, + (overlapping) => { + overlappingOverlayPresentRef.current = overlapping + return scheduleReconcile() + } + ) + return () => { + stop() + overlappingOverlayPresentRef.current = false + void scheduleReconcile() + } + }, [activeTabId, panelVisible, scheduleReconcile]) + useEffect(() => { mountedRef.current = true return () => { mountedRef.current = false panelVisibleRef.current = false screenOcclusionPresentRef.current = false + overlappingOverlayPresentRef.current = false pendingOverlayRef.current = null activeOverlayRef.current = null activeOverlayOwnershipLostRef.current = null transitionVersionRef.current++ + cancelPreparationRef.current?.() + cancelPreparationRef.current = null cancelPendingPaint() // Run once now and once behind any in-flight capture/hide. The second // reveal closes the only race where unmount lands during the hide IPC. @@ -629,5 +783,6 @@ export function useBrowserPanelOcclusion( requestOverlay, closeOverlay, onSnapshotError, + shouldKeepNativeHidden, } } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx index e3170db1f84..9fda00a34e0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-session.tsx @@ -291,7 +291,7 @@ export function browserPanelSnapshotStyle( width: bounds.width, height: bounds.height, maxWidth: 'none', - zIndex: layer === 'modal' ? 'calc(var(--z-modal) - 1)' : 'calc(var(--z-popover) - 1)', + zIndex: layer === 'modal' ? 'calc(var(--z-modal) - 1)' : 'calc(var(--z-dropdown) - 1)', } } @@ -492,6 +492,7 @@ export function BrowserSession({ requestOverlay, closeOverlay, onSnapshotError, + shouldKeepNativeHidden, } = useBrowserPanelOcclusion(scopeId, activeTabId, panelVisible, getHostRect) const respondToPermission = useCallback( @@ -766,14 +767,18 @@ export function BrowserSession({ let disposed = false let occlusionRequest = 0 const atomicPanelOcclusion = supportsAtomicBrowserPanelOcclusion() - let occlusionPresent = atomicPanelOcclusion && hasNativeSurfaceOcclusion() + const hasRequestedOcclusion = () => + atomicPanelOcclusion && (hasNativeSurfaceOcclusion() || shouldKeepNativeHidden()) + let occlusionPresent = hasRequestedOcclusion() // A full-screen marker can exist before this Browser reports its first // rect. In that path this bounds effect acquires a serialized hidden lease // before attaching geometry, including rollback if the marker disappears // while Electron is still processing the hide. - const geometryOcclusionLease = createBrowserPanelGeometryOcclusionLease((occluded) => - setBrowserPanelOccluded(occluded, scopeId, occluded).catch(() => false) - ) + const geometryOcclusionLease = createBrowserPanelGeometryOcclusionLease((occluded) => { + /** The snapshot controller retains ownership if a tooltip or menu outlives the modal. */ + if (!occluded && shouldKeepNativeHidden()) return Promise.resolve(true) + return setBrowserPanelOccluded(occluded, scopeId, occluded).catch(() => false) + }) const commitGeometry = ( bounds: BrowserPanelBounds, @@ -814,7 +819,7 @@ export function BrowserSession({ } const anchor = describeAnchor(panel) - const nativeSurfaceOcclusionPresent = atomicPanelOcclusion && hasNativeSurfaceOcclusion() + const nativeSurfaceOcclusionPresent = hasRequestedOcclusion() const request = ++occlusionRequest if ( @@ -833,14 +838,13 @@ export function BrowserSession({ // resets panelOccluded — while this side still remembers `applied: // true`. Without dropping that belief, setDesired(true) is a no-op, // the next bounds commit lays out an unoccluded native view, and the - // browser punches above the still-open modal with nothing left to - // ever re-hide it. Forgetting `applied` costs one idempotent hide IPC - // per heartbeat while a modal is up, and makes any main-side lease - // loss self-heal within a second. + // browser punches above the still-open overlay. Forgetting `applied` + // reasserts the lease for modals and painted transient overlays on each + // heartbeat, recovering main-side lease loss within a second. if (nativeSurfaceOcclusionPresent) geometryOcclusionLease.assumeRevealed() void geometryOcclusionLease.setDesired(nativeSurfaceOcclusionPresent).then((settled) => { if (disposed) return - const latestOcclusionPresent = atomicPanelOcclusion && hasNativeSurfaceOcclusion() + const latestOcclusionPresent = hasRequestedOcclusion() if ( request !== occlusionRequest || latestOcclusionPresent !== nativeSurfaceOcclusionPresent @@ -876,7 +880,7 @@ export function BrowserSession({ const resizeObserver = new ResizeObserver(() => reportGeometry(false)) const occlusionObserver = new MutationObserver((records) => { if (!mutationsTouchNativeSurfaceOcclusion(records)) return - const next = hasNativeSurfaceOcclusion() + const next = hasRequestedOcclusion() if (next === occlusionPresent) return occlusionPresent = next scheduleGeometryReport(true) @@ -917,7 +921,7 @@ export function BrowserSession({ void geometryOcclusionLease.setDesired(false) reportBrowserPanelBounds(null, null, scopeId) } - }, [hasRendererPage, visible, suspended, scopeId]) + }, [hasRendererPage, visible, suspended, scopeId, shouldKeepNativeHidden]) /** * Programmatic focus on a new tab keeps the omnibox ready for typing without diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts index 8507e6beafb..1fabaadeb50 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls.ts @@ -21,9 +21,8 @@ export const RESOURCE_HEADER_CLASSES = { * where the toggle and the action buttons are bare glyphs whose box only shows * on hover. * - * The tab width cap is chosen per strip from the tab count (see - * {@link resourceTabWidthClass}), so a title can breathe while the strip is - * roomy and only tightens once the tabs start competing for the width. + * The strip sizes tabs from the available width, including the space taken + * by header actions, and scrolls once every tab reaches its minimum width. */ stripGeometry: '[--tab-strip-height:calc(var(--resource-header-controls-height)_+_1px)] [--tab-strip-band:26px] [--tab-strip-inline-start:var(--resource-header-end-inset)] [--tab-strip-inline-end:var(--resource-header-fixed-reserve)]', @@ -42,16 +41,3 @@ export const RESOURCE_HEADER_CLASSES = { 'right-[calc(var(--resource-header-end-inset)_+_var(--resource-header-toggle-hit-size)_+_1px)]', emptyAddOffset: '-translate-x-1.5', } as const - -/** - * Width cap for the strip's tabs, from the tab count. A couple of tabs have - * the room to show more of their titles; each added tab tightens the cap by a - * step small enough to pass unnoticed, down to the floor a full strip needs so - * every tab stays visible for longer before the strip scrolls. Only the - * ellipsis point moves — a title that already fits never changes width. - */ -export function resourceTabWidthClass(tabCount: number): string { - if (tabCount <= 2) return '[--tab-strip-max-tab-width:200px]' - if (tabCount === 3) return '[--tab-strip-max-tab-width:180px]' - return '[--tab-strip-max-tab-width:160px]' -} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx index 2dc34e43b10..c5d8a5e8095 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tabs.tsx @@ -43,7 +43,6 @@ import { getResourceConfig } from '@/app/workspace/[workspaceId]/home/components import { RESOURCE_HEADER_CLASSES, RESOURCE_TAB_ICON_CLASS, - resourceTabWidthClass, } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-tabs/resource-tab-controls' import type { MothershipResource, @@ -604,10 +603,7 @@ export function ResourceTabs({ onReorder={handleReorder} onTabDragStart={handleTabDragStart} variant='floating' - className={cn( - RESOURCE_HEADER_CLASSES.stripGeometry, - resourceTabWidthClass(resources.length) - )} + className={RESOURCE_HEADER_CLASSES.stripGeometry} newTabControl={ // Offered before the chat exists too: a resource opened while composing // the first prompt is context for that prompt, and gating on a chat id diff --git a/apps/sim/components/ui/select.tsx b/apps/sim/components/ui/select.tsx index bcae7e95d48..fd7997d097b 100644 --- a/apps/sim/components/ui/select.tsx +++ b/apps/sim/components/ui/select.tsx @@ -73,7 +73,7 @@ const SelectContent = React.forwardRef< = { attached: 'w-[156px] min-w-[96px] shrink', - floating: 'max-w-[var(--tab-strip-max-tab-width,200px)] shrink-0', + floating: 'min-w-[64px] max-w-[var(--tab-strip-max-tab-width,200px)] shrink', } /** The resting shape of a tab that is not the active one. */ diff --git a/packages/emcn/src/components/toast/toast.tsx b/packages/emcn/src/components/toast/toast.tsx index 6c8d1d4e37b..4c4e31c05a4 100644 --- a/packages/emcn/src/components/toast/toast.tsx +++ b/packages/emcn/src/components/toast/toast.tsx @@ -694,7 +694,7 @@ export function ToastProvider({ children }: { children?: ReactNode }) { key='toast-stack' aria-live='polite' aria-label='Notifications' - data-native-surface-overlay='' + data-native-surface-overlay='passive' /* * The stack is portalled to ``, so it shares no ancestor * with the panel or terminal it insets by. A resize drag writes