Skip to content

Commit f7d9c65

Browse files
committed
fix(ui): compact crowded tabs and layer native browser overlays
1 parent 7c115a1 commit f7d9c65

12 files changed

Lines changed: 687 additions & 84 deletions

File tree

‎.github/workflows/desktop-e2e.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ on:
1010
- '.github/workflows/desktop-e2e.yml'
1111
- '.github/workflows/desktop-release.yml'
1212
- 'apps/desktop/**'
13+
- 'apps/sim/app/workspace/**/browser-session/**'
1314
- 'apps/sim/app/_styles/**'
1415
- 'apps/sim/lib/postcss/**'
1516
- 'apps/sim/postcss.config.mjs'
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import { _electron as electron, expect, test } from '@playwright/test'
7+
import type { SimDesktopApi } from '@sim/desktop-bridge'
8+
import { build } from 'esbuild'
9+
import postcss from 'postcss'
10+
import loadPostcssConfig from 'postcss-load-config'
11+
12+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
13+
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
14+
const FIXTURE = fileURLToPath(new URL('./fixtures/browser-chrome.tsx', import.meta.url))
15+
const SCOPE = 'browser-chrome-fixture'
16+
17+
test('crowded tabs and renderer overlays work with a real native browser page', async () => {
18+
const testInfo = test.info()
19+
let server: Server | undefined
20+
let userData: string | undefined
21+
let app: Awaited<ReturnType<typeof electron.launch>> | undefined
22+
try {
23+
const config = await loadPostcssConfig({}, SIM_DIR)
24+
const stylesheet = join(SIM_DIR, 'app/_styles/globals.css')
25+
const css = await postcss(config.plugins).process(
26+
`${readFileSync(stylesheet, 'utf8')}\n@source ${JSON.stringify(FIXTURE)};`,
27+
{ from: stylesheet }
28+
)
29+
const hook = join(
30+
SIM_DIR,
31+
'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session/browser-panel-occlusion.ts'
32+
)
33+
const bundle = await build({
34+
stdin: {
35+
contents: `import { mountBrowserChromeFixture } from ${JSON.stringify(FIXTURE)};
36+
import { useBrowserPanelOcclusion } from ${JSON.stringify(hook)};
37+
mountBrowserChromeFixture(useBrowserPanelOcclusion);`,
38+
resolveDir: SIM_DIR,
39+
loader: 'tsx',
40+
},
41+
bundle: true,
42+
write: false,
43+
outfile: testInfo.outputPath('fixture.js'),
44+
external: ['node:async_hooks'],
45+
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
46+
format: 'iife',
47+
platform: 'browser',
48+
tsconfig: join(SIM_DIR, 'tsconfig.json'),
49+
define: { 'process.env.NODE_ENV': '"development"' },
50+
})
51+
server = createServer((request, response) => {
52+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
53+
if (path === '/fixture.js' || path === '/fixture.css') {
54+
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
55+
response.end(
56+
path.endsWith('.js')
57+
? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
58+
: css.css
59+
)
60+
return
61+
}
62+
if (path.startsWith('/api/')) {
63+
response.setHeader('Content-Type', 'application/json')
64+
response.end(
65+
path === '/api/auth/get-session'
66+
? JSON.stringify({ user: { id: 'fixture-user' }, session: { id: 'fixture-session' } })
67+
: '{}'
68+
)
69+
return
70+
}
71+
response.writeHead(200, {
72+
'Content-Type': 'text/html',
73+
'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; SameSite=Lax; Path=/',
74+
})
75+
response.end(
76+
path === '/page'
77+
? '<!doctype html><html><body style="background:#192b40;color:white;font:24px system-ui;padding:25px"><h1>Browser fixture</h1><p>A live page behind the application chrome.</p><button>Page action</button></body></html>'
78+
: '<!doctype html><html class="dark"><head><link rel="stylesheet" href="/fixture.css"></head><body style="margin:0;background:#191919;color:#eee"><div id="root"></div><script src="/fixture.js"></script></body></html>'
79+
)
80+
})
81+
await new Promise<void>((resolve) => server?.listen(0, resolve))
82+
const address = server.address()
83+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
84+
userData = mkdtempSync(join(tmpdir(), 'sim-browser-chrome-e2e-'))
85+
app = await electron.launch({
86+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
87+
cwd: DESKTOP_DIR,
88+
env: {
89+
...process.env,
90+
SIM_DESKTOP_ORIGIN: `http://127.0.0.1:${address.port}`,
91+
SIM_DESKTOP_USER_DATA: userData,
92+
},
93+
})
94+
const shell = app
95+
const page = await shell.firstWindow()
96+
await shell.evaluate(({ app, BrowserWindow }) => {
97+
const window = BrowserWindow.getAllWindows()[0]
98+
window.setContentSize(1100, 750)
99+
window.webContents.setBackgroundThrottling(false)
100+
app.focus({ steal: true })
101+
window.focus()
102+
})
103+
const nativeVisible = () =>
104+
shell.evaluate(({ BrowserWindow, WebContentsView }) =>
105+
BrowserWindow.getAllWindows()[0]
106+
.contentView.children.find(
107+
(view) => view instanceof WebContentsView && view.webContents.getURL().endsWith('/page')
108+
)
109+
?.getVisible()
110+
)
111+
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(8)
112+
await test.step('Eight tabs shrink to available width without early overflow', async () => {
113+
const geometry = await page.locator('[data-tab-strip-item]').evaluateAll((tabs) =>
114+
tabs.map((tab) => {
115+
const bounds = tab.getBoundingClientRect()
116+
return { width: bounds.width, right: bounds.right }
117+
})
118+
)
119+
expect(geometry.every((tab) => tab.width >= 96 && tab.width < 160)).toBe(true)
120+
expect(geometry.at(-1)?.right).toBeLessThan(1070)
121+
await page.screenshot({ path: testInfo.outputPath('tabs.png') })
122+
})
123+
await test.step('Crowded tabs keep a readable minimum and scroll', async () => {
124+
await page.locator('#many-tabs').click()
125+
await expect(page.locator('[data-tab-strip-item]')).toHaveCount(18)
126+
const overflow = await page
127+
.locator('[data-tab-strip-item]')
128+
.first()
129+
.evaluate((tab) => ({
130+
width: tab.getBoundingClientRect().width,
131+
scrollWidth: tab.parentElement?.scrollWidth ?? 0,
132+
clientWidth: tab.parentElement?.clientWidth ?? 0,
133+
}))
134+
expect(overflow.width).toBeGreaterThanOrEqual(96)
135+
expect(overflow.scrollWidth).toBeGreaterThan(overflow.clientWidth)
136+
await page.locator('#eight-tabs').click()
137+
})
138+
await test.step('An open menu recovers when no native page was available for its initial capture', async () => {
139+
await page.locator('#menu-trigger').click()
140+
await expect(page.getByRole('menu')).toBeVisible()
141+
expect(
142+
await page.evaluate(
143+
(scope) =>
144+
(
145+
globalThis as typeof globalThis & { simDesktop: SimDesktopApi }
146+
).simDesktop.browserAgent.capturePanelSnapshot(scope),
147+
SCOPE
148+
)
149+
).toBeNull()
150+
await expect(page.locator('#snapshot')).toHaveCount(0)
151+
await page
152+
.locator('#start-native')
153+
.evaluate((button) => (button as HTMLButtonElement).click())
154+
await expect.poll(nativeVisible).toBe(false)
155+
await expect(page.locator('#snapshot')).toBeVisible()
156+
await expect(page.getByRole('menu')).toBeVisible()
157+
await page.keyboard.press('Escape')
158+
await expect.poll(nativeVisible).toBe(true)
159+
})
160+
await test.step('Tooltip appears above the real native browser', async () => {
161+
await page.locator('#reference').hover()
162+
await expect(page.getByRole('tooltip')).toBeVisible()
163+
await expect.poll(nativeVisible).toBe(false)
164+
await expect(page.locator('#snapshot')).toBeVisible()
165+
await page.screenshot({ path: testInfo.outputPath('tooltip.png') })
166+
})
167+
await test.step('Leaving the tooltip restores the native browser', async () => {
168+
await page.mouse.move(200, 180)
169+
await expect.poll(nativeVisible).toBe(true)
170+
await expect(page.locator('#snapshot')).toHaveCount(0)
171+
})
172+
await test.step('An open menu remains above the page when its item tooltip disappears', async () => {
173+
await page.locator('#menu-trigger').click()
174+
await expect(page.getByRole('menu')).toBeVisible()
175+
await expect.poll(nativeVisible).toBe(false)
176+
await page.locator('#tooltip-item').hover()
177+
await expect(page.getByRole('tooltip')).toBeVisible()
178+
await page.locator('#plain-item').hover()
179+
await expect(page.getByRole('tooltip')).toHaveCount(0)
180+
await expect(page.getByRole('menu')).toBeVisible()
181+
await expect.poll(nativeVisible).toBe(false)
182+
await expect(page.locator('#snapshot')).toBeVisible()
183+
})
184+
await test.step('Resizing with an open menu refreshes the captured viewport', async () => {
185+
await shell.evaluate(({ BrowserWindow }) =>
186+
BrowserWindow.getAllWindows()[0].setContentSize(1000, 750)
187+
)
188+
await expect
189+
.poll(() =>
190+
page.locator('#snapshot').evaluate((image) => image.getBoundingClientRect().width)
191+
)
192+
.toBe(450)
193+
await expect.poll(nativeVisible).toBe(false)
194+
await page.screenshot({ path: testInfo.outputPath('menu.png') })
195+
})
196+
await test.step('Escape dismisses the menu and restores the native browser', async () => {
197+
await page.keyboard.press('Escape')
198+
await expect(page.getByRole('menu')).toHaveCount(0)
199+
await expect.poll(nativeVisible).toBe(true)
200+
await expect(page.locator('#snapshot')).toHaveCount(0)
201+
})
202+
} finally {
203+
await app?.close()
204+
if (server?.listening) {
205+
const listener = server
206+
await new Promise<void>((resolve, reject) =>
207+
listener.close((error) => (error ? reject(error) : resolve()))
208+
)
209+
}
210+
if (userData) rmSync(userData, { recursive: true, force: true })
211+
}
212+
})
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { useEffect, useRef, useState } from 'react'
2+
import type { BrowserPanelSnapshot } from '@sim/browser-protocol'
3+
import type { SimDesktopApi } from '@sim/desktop-bridge'
4+
import {
5+
Button,
6+
cn,
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuItem,
10+
DropdownMenuTrigger,
11+
TabStrip,
12+
Tooltip,
13+
} from '@sim/emcn'
14+
import { File } from '@sim/emcn/icons'
15+
import { getErrorMessage } from '@sim/utils/errors'
16+
import { createPortal } from 'react-dom'
17+
import { createRoot } from 'react-dom/client'
18+
19+
const SCOPE = 'browser-chrome-fixture'
20+
21+
interface BrowserChromeFixtureProps {
22+
useOcclusion: (
23+
scopeId: string,
24+
activeTabId: string | null,
25+
visible: boolean,
26+
getHostRect: () => DOMRect | null
27+
) => {
28+
snapshot: BrowserPanelSnapshot | null
29+
snapshotLayer: 'modal' | 'popover'
30+
onSnapshotError: () => void
31+
}
32+
}
33+
34+
function reportPanelBounds(api: SimDesktopApi, host: HTMLDivElement | null) {
35+
const bounds = host?.getBoundingClientRect()
36+
if (!bounds) return
37+
api.browserAgent.setPanelBounds(
38+
{ x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height },
39+
null,
40+
SCOPE
41+
)
42+
}
43+
44+
function BrowserChromeFixture({ useOcclusion }: BrowserChromeFixtureProps) {
45+
const host = useRef<HTMLDivElement>(null)
46+
const [activeTabId, setActiveTabId] = useState<string | null>(null)
47+
const [selected, setSelected] = useState('tab-0')
48+
const [tabCount, setTabCount] = useState(8)
49+
const [error, setError] = useState<string | null>(null)
50+
const api = (globalThis as typeof globalThis & { simDesktop: SimDesktopApi }).simDesktop
51+
const { snapshot, snapshotLayer, onSnapshotError } = useOcclusion(
52+
SCOPE,
53+
activeTabId,
54+
true,
55+
() => host.current?.getBoundingClientRect() ?? null
56+
)
57+
58+
const startBrowser = async () => {
59+
if (!api.browserAgent.openUrl) throw new Error('Native browser bridge is unavailable')
60+
await api.browserAgent.activateScope(SCOPE)
61+
const tabs = await api.browserAgent.openUrl(
62+
`${location.origin.replace('127.0.0.1', 'localhost')}/page`,
63+
SCOPE
64+
)
65+
setActiveTabId(tabs.activeTabId)
66+
reportPanelBounds(api, host.current)
67+
}
68+
69+
useEffect(() => {
70+
const element = host.current
71+
if (!element) return
72+
const observer = new ResizeObserver(() => reportPanelBounds(api, element))
73+
observer.observe(element)
74+
const heartbeat = window.setInterval(() => reportPanelBounds(api, element), 1_000)
75+
return () => {
76+
window.clearInterval(heartbeat)
77+
observer.disconnect()
78+
api.browserAgent.setPanelBounds(null, null, SCOPE)
79+
}
80+
}, [api])
81+
82+
return (
83+
<>
84+
<div className='flex gap-2 pt-[32px]'>
85+
<Button
86+
id='start-native'
87+
onClick={() => void startBrowser().catch((cause) => setError(getErrorMessage(cause)))}
88+
>
89+
Start browser
90+
</Button>
91+
<Button id='eight-tabs' onClick={() => setTabCount(8)}>
92+
Eight tabs
93+
</Button>
94+
<Button id='many-tabs' onClick={() => setTabCount(18)}>
95+
Many tabs
96+
</Button>
97+
</div>
98+
{error && <p role='alert'>{error}</p>}
99+
<TabStrip
100+
tabs={Array.from({ length: tabCount }, (_, index) => ({
101+
id: `tab-${index}`,
102+
title: `Example resource ${index + 1} with a descriptive title`,
103+
icon: <File className='size-[16px] shrink-0' />,
104+
active: selected === `tab-${index}`,
105+
}))}
106+
variant='floating'
107+
onSelect={setSelected}
108+
onClose={() => undefined}
109+
onNew={() => setTabCount((count) => count + 1)}
110+
/>
111+
<div className='absolute top-[260px] left-[340px] w-[190px]'>
112+
<Tooltip.Root>
113+
<Tooltip.Trigger asChild>
114+
<Button id='reference'>Example reference</Button>
115+
</Tooltip.Trigger>
116+
<Tooltip.Content>
117+
A descriptive reference tooltip that extends across the browser boundary.
118+
</Tooltip.Content>
119+
</Tooltip.Root>
120+
</div>
121+
<div className='absolute top-[370px] left-[420px]'>
122+
<DropdownMenu>
123+
<DropdownMenuTrigger asChild>
124+
<Button id='menu-trigger'>Open menu</Button>
125+
</DropdownMenuTrigger>
126+
<DropdownMenuContent align='start' className='w-[280px]'>
127+
<DropdownMenuItem id='plain-item'>Open example item</DropdownMenuItem>
128+
<Tooltip.Root>
129+
<Tooltip.Trigger asChild>
130+
<DropdownMenuItem id='tooltip-item'>Preview details</DropdownMenuItem>
131+
</Tooltip.Trigger>
132+
<Tooltip.Content>Additional details for this example menu item.</Tooltip.Content>
133+
</Tooltip.Root>
134+
</DropdownMenuContent>
135+
</DropdownMenu>
136+
</div>
137+
<div
138+
ref={host}
139+
id='browser-host'
140+
className='absolute top-[160px] right-0 bottom-0 left-[550px]'
141+
/>
142+
{snapshot &&
143+
createPortal(
144+
<img
145+
id='snapshot'
146+
src={snapshot.dataUrl}
147+
width={snapshot.viewportBounds?.width}
148+
height={snapshot.viewportBounds?.height}
149+
onError={onSnapshotError}
150+
className={cn(
151+
'pointer-events-none fixed top-[160px] left-[550px] max-w-none',
152+
snapshotLayer === 'modal'
153+
? 'z-[calc(var(--z-modal)-1)]'
154+
: 'z-[calc(var(--z-dropdown)-1)]'
155+
)}
156+
/>,
157+
document.body
158+
)}
159+
</>
160+
)
161+
}
162+
163+
/** The real Sim hook is supplied by the bundle entry using Sim's module aliases. */
164+
export function mountBrowserChromeFixture(useOcclusion: BrowserChromeFixtureProps['useOcclusion']) {
165+
const root = document.getElementById('root')
166+
if (!root) throw new Error('Missing fixture root')
167+
createRoot(root).render(<BrowserChromeFixture useOcclusion={useOcclusion} />)
168+
}

0 commit comments

Comments
 (0)