Skip to content

Commit 41040be

Browse files
committed
fix(desktop-browser): preserve native dialog ownership and keyboard focus
1 parent 464ed21 commit 41040be

8 files changed

Lines changed: 281 additions & 159 deletions

File tree

‎apps/desktop/e2e/browser-page-dialogs.spec.ts‎

Lines changed: 202 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
1+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
22
import { createServer, type Server } from 'node:http'
33
import { tmpdir } from 'node:os'
44
import { dirname, join } from 'node:path'
55
import { fileURLToPath } from 'node:url'
66
import {
7+
type Dialog,
78
type ElectronApplication,
89
_electron as electron,
910
expect,
@@ -13,12 +14,18 @@ import {
1314
import type { BrowserPageDialog, BrowserToolName } from '@sim/browser-protocol'
1415
import type { SimDesktopApi } from '@sim/desktop-bridge'
1516
import { getErrorMessage } from '@sim/utils/errors'
17+
import { build } from 'esbuild'
18+
import postcss from 'postcss'
19+
import loadPostcssConfig from 'postcss-load-config'
1620

1721
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
1822
const SCOPE = 'browser-page-dialogs-e2e'
19-
const SHELL_FIXTURE = '<!doctype html><title>Sim fixture</title><h1>Browser dialogs fixture</h1>'
23+
const SIM_DIR = fileURLToPath(new URL('../../sim/', import.meta.url))
24+
const SHELL_FIXTURE =
25+
'<!doctype html><title>Sim fixture</title><link rel="stylesheet" href="/fixture.css"><h1>Browser dialogs fixture</h1><div id="root"></div><div id="native-panel" style="position:absolute;top:80px;bottom:0;left:0;right:0"></div><script src="/fixture.js"></script>'
2026
const FORM_FIXTURE = `<!doctype html><title>form</title>
2127
<input id="draft" aria-label="Draft">
28+
<button id="long-message" onclick="alert('Details '.repeat(150) + 'Final decision detail')">Long message</button>
2229
<button id="delete" onclick="document.title = 'confirm:' + confirm('Delete the report?')">Delete</button>
2330
<script>addEventListener('beforeunload', (event) => {
2431
if (document.getElementById('draft').value) { event.preventDefault(); event.returnValue = '' }
@@ -27,6 +34,7 @@ const FORM_FIXTURE = `<!doctype html><title>form</title>
2734
type Bridge = typeof globalThis & {
2835
simDesktop: SimDesktopApi
2936
pageDialog?: BrowserPageDialog | null
37+
pageIssue?: string | null
3038
boundsTimer?: number
3139
}
3240

@@ -60,9 +68,64 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
6068
throw error
6169
}
6270
}
71+
const browserComponents = join(
72+
SIM_DIR,
73+
'app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/components/browser-session'
74+
)
75+
const bundle = await build({
76+
stdin: {
77+
contents: `import { createElement } from 'react';
78+
import { createRoot } from 'react-dom/client';
79+
import { useBrowserSessionStore as store } from ${JSON.stringify(join(SIM_DIR, 'stores/browser-session/store.ts'))};
80+
import { BrowserPageDialogModal } from ${JSON.stringify(join(browserComponents, 'browser-page-dialog.tsx'))};
81+
import { useBrowserPanelOcclusion } from ${JSON.stringify(join(browserComponents, 'browser-panel-occlusion.ts'))};
82+
const scope = ${JSON.stringify(SCOPE)};
83+
const api = globalThis.simDesktop.browserAgent;
84+
api.onPageState(state => store.getState().setPageState(state));
85+
store.subscribe(state => {
86+
globalThis.pageDialog = state.sessions[scope]?.pageState?.dialog ?? null;
87+
globalThis.pageIssue = state.sessions[scope]?.pageState?.issue?.kind ?? null;
88+
});
89+
function Fixture() {
90+
const page = store(state => state.sessions[scope]?.pageState);
91+
useBrowserPanelOcclusion(scope, page?.tabId ?? null, true, () => document.getElementById('native-panel')?.getBoundingClientRect() ?? null);
92+
return createElement(BrowserPageDialogModal, {
93+
dialog: page?.dialog,
94+
open: Boolean(page?.dialog),
95+
onAnswer: (requestId, allowed) => api.panelAction({action:'respond-dialog',requestId,allowed},scope),
96+
});
97+
}
98+
createRoot(document.getElementById('root')).render(createElement(Fixture));`,
99+
resolveDir: SIM_DIR,
100+
loader: 'tsx',
101+
},
102+
bundle: true,
103+
write: false,
104+
outfile: test.info().outputPath('fixture.js'),
105+
external: ['node:async_hooks'],
106+
banner: { js: 'var process={env:{NODE_ENV:"development"},browser:true};' },
107+
format: 'iife',
108+
platform: 'browser',
109+
tsconfig: join(SIM_DIR, 'tsconfig.json'),
110+
define: { 'process.env.NODE_ENV': '"development"' },
111+
})
112+
const config = await loadPostcssConfig({}, SIM_DIR)
113+
const stylesheet = join(SIM_DIR, 'app/_styles/globals.css')
114+
const css = await postcss(config.plugins).process(readFileSync(stylesheet, 'utf8'), {
115+
from: stylesheet,
116+
})
63117
const calls = new Map<string, { chatId: string; toolName: BrowserToolName; args: unknown }>()
64118
const server: Server = createServer(async (request, response) => {
65119
const path = new URL(request.url ?? '/', 'http://localhost').pathname
120+
if (path === '/fixture.js' || path === '/fixture.css') {
121+
response.setHeader('Content-Type', path.endsWith('.js') ? 'text/javascript' : 'text/css')
122+
response.end(
123+
path.endsWith('.js')
124+
? bundle.outputFiles.find((file) => file.path.endsWith('.js'))?.text
125+
: css.css
126+
)
127+
return
128+
}
66129
if (path === '/api/desktop/tool/authorize') {
67130
let body = ''
68131
for await (const chunk of request) body += chunk.toString()
@@ -71,6 +134,10 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
71134
response.end(JSON.stringify(authorization ?? {}))
72135
return
73136
}
137+
if (path === '/broken') {
138+
response.destroy()
139+
return
140+
}
74141
// The app origin serves the shell; the same server on localhost is the web.
75142
const isSite = request.headers.host?.startsWith('localhost') === true
76143
response.writeHead(200, {
@@ -102,10 +169,24 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
102169
app = shellApp
103170
// A dialog listener stops Playwright auto-dismissing page dialogs, so the desktop's own
104171
// handling decides their outcome exactly as it does in production.
105-
const leaveDialogsToDesktop = (page: Page) => page.on('dialog', () => {})
172+
const nativeDialogs: Dialog[] = []
173+
const leaveDialogsToDesktop = (page: Page) =>
174+
page.on('dialog', (dialog) => nativeDialogs.push(dialog))
175+
const nextNativeDialog = async () => {
176+
await expect.poll(() => nativeDialogs.length).toBeGreaterThan(0)
177+
const dialog = nativeDialogs.shift()
178+
if (!dialog) throw new Error('Missing native dialog')
179+
return dialog
180+
}
106181
shellApp.context().pages().forEach(leaveDialogsToDesktop)
107182
shellApp.context().on('page', leaveDialogsToDesktop)
108183
const shell = await shellApp.firstWindow()
184+
await shellApp.evaluate(({ app, BrowserWindow }) => {
185+
const host = BrowserWindow.getAllWindows()[0]
186+
if (!host) throw new Error('Missing host window')
187+
app.focus({ steal: true })
188+
host.focus()
189+
})
109190
await expect(shell.getByRole('heading')).toHaveText('Browser dialogs fixture')
110191
await shell.evaluate(async (scope) => {
111192
const bridge = globalThis as Bridge
@@ -120,9 +201,6 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
120201
updateBounds()
121202
bridge.boundsTimer = window.setInterval(updateBounds, 200)
122203
bridge.pageDialog = null
123-
api.onPageState((state) => {
124-
bridge.pageDialog = state.dialog ?? null
125-
})
126204
}, SCOPE)
127205

128206
let callCount = 0
@@ -139,6 +217,15 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
139217
}
140218
/** Browser-chrome actions are user gestures, so each follows a real click in Sim. */
141219
const panelAction = async (action: Record<string, unknown>) => {
220+
if (action.action === 'respond-dialog' && (await shell.getByRole('dialog').count())) {
221+
await shell
222+
.getByRole('button', {
223+
name: action.allowed ? 'Leave' : 'Stay',
224+
exact: true,
225+
})
226+
.click()
227+
return
228+
}
142229
await shell.getByRole('heading').click()
143230
await shell.evaluate(
144231
({ action, scope }) =>
@@ -209,16 +296,51 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
209296
await panelAction({ action: 'enable-page-dialogs' })
210297
await inPage("document.title = 'form'")
211298

212-
await check("the user's confirm waits for their answer", async () => {
299+
nativeDialogs.length = 0
300+
await check("the user's confirm waits for their answer without a duplicate modal", async () => {
213301
await userInput('#delete')
214-
await expect
215-
.poll(pageDialog)
216-
.toMatchObject({ kind: 'confirm', message: 'Delete the report?' })
302+
const dialog = await nextNativeDialog()
303+
expect(dialog.type()).toBe('confirm')
304+
expect(dialog.message()).toBe('Delete the report?')
217305
expect(await pageTitle()).toBe('form')
218-
const dialog = await pageDialog()
219-
await panelAction({ action: 'respond-dialog', requestId: dialog?.requestId, allowed: true })
306+
expect(await pageDialog()).toBeNull()
307+
await expect(shell.getByRole('dialog')).toHaveCount(0)
308+
await dialog.accept()
309+
await expect.poll(() => pageTitle()).toBe('confirm:true')
310+
})
311+
312+
await check('a user sees the complete decision text beyond the diagnostic limit', async () => {
313+
await userInput('#long-message')
314+
const dialog = await nextNativeDialog()
315+
expect(dialog.message()).toBe(`${'Details '.repeat(150)}Final decision detail`)
316+
await dialog.accept()
317+
})
318+
319+
await check('automation leaves a user-owned confirmation unanswered', async () => {
320+
await inPage("document.title = 'form'")
321+
await userInput('#delete')
322+
const dialog = await nextNativeDialog()
323+
const callId = `browser-dialogs-${++callCount}`
324+
calls.set(callId, { chatId: SCOPE, toolName: 'browser_snapshot', args: {} })
325+
const result = await shell.evaluate(
326+
({ callId, scope }) =>
327+
(globalThis as Bridge).simDesktop.browserAgent.executeTool(
328+
callId,
329+
'browser_snapshot',
330+
{},
331+
scope
332+
),
333+
{ callId, scope: SCOPE }
334+
)
335+
expect(result.ok).toBe(false)
336+
await shell.evaluate(
337+
({ callId, scope }) =>
338+
(globalThis as Bridge).simDesktop.browserAgent.cancelTool?.(callId, scope),
339+
{ callId, scope: SCOPE }
340+
)
341+
expect(await pageTitle()).toBe('form')
342+
await dialog.accept()
220343
await expect.poll(() => pageTitle()).toBe('confirm:true')
221-
await expect.poll(pageDialog).toBeNull()
222344
})
223345

224346
await check("the agent's dialogs never wait on the user", async () => {
@@ -237,6 +359,11 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
237359
expect(await pageDialog()).toBeNull()
238360
})
239361

362+
// CDP answers the JavaScript call but Electron owns its native sheet. Reload
363+
// ends the native dialogs before testing the renderer's leave-site modal.
364+
await panelAction({ action: 'reload' })
365+
await expect.poll(() => pageTitle()).toBe('form')
366+
240367
await check('leaving a draft from the URL bar asks, and Stay keeps it', async () => {
241368
await userInput('#draft', 'draft')
242369
await expect
@@ -260,6 +387,25 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
260387
expect(await inPage<string>("document.getElementById('draft').value")).toBe('draft')
261388
})
262389

390+
await check('Escape keeps the draft and returns typing to the page', async () => {
391+
await userInput('#draft')
392+
await panelAction({ action: 'reload' })
393+
await expect(shell.getByRole('button', { name: 'Stay', exact: true })).toBeFocused()
394+
await shell.keyboard.press('Escape')
395+
await expect.poll(pageDialog).toBeNull()
396+
await expect
397+
.poll(() =>
398+
shellApp.evaluate(({ webContents }) => webContents.getFocusedWebContents()?.getURL())
399+
)
400+
.toBe(`${site}/form`)
401+
await shellApp.evaluate(({ webContents }) => {
402+
const contents = webContents.getFocusedWebContents()
403+
if (!contents) throw new Error('Missing focused page')
404+
contents.sendInputEvent({ type: 'char', keyCode: 'x' })
405+
})
406+
expect(await inPage<string>("document.getElementById('draft').value")).toBe('xdraft')
407+
})
408+
263409
await check('Leave lets the navigation through without asking again', async () => {
264410
await panelAction({ action: 'navigate', url: `${site}/next` })
265411
await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' })
@@ -268,6 +414,41 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
268414
await expect.poll(pageUrl).toBe(`${site}/next`)
269415
expect(await pageDialog()).toBeNull()
270416
})
417+
await check('a crashed page drops its pending Leave decision', async () => {
418+
await panelAction({ action: 'navigate', url: `${site}/form` })
419+
await expect.poll(pageUrl).toBe(`${site}/form`)
420+
await userInput('#draft', 'draft')
421+
await panelAction({ action: 'reload' })
422+
await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' })
423+
const stale = await pageDialog()
424+
await shellApp.evaluate(({ webContents }, url) => {
425+
webContents
426+
.getAllWebContents()
427+
.find((contents) => contents.getURL() === url)
428+
?.forcefullyCrashRenderer()
429+
}, `${site}/form`)
430+
await expect.poll(pageDialog).toBeNull()
431+
await panelAction({ action: 'respond-dialog', requestId: stale?.requestId, allowed: true })
432+
await panelAction({ action: 'reload' })
433+
await expect.poll(() => pageTitle()).toBe('form')
434+
await expect.poll(pageDialog).toBeNull()
435+
})
436+
437+
await check('Back from a failed load does not capture a later page navigation', async () => {
438+
await userInput('#draft', 'draft')
439+
await panelAction({ action: 'navigate', url: `${site}/broken` })
440+
await expect.poll(pageDialog).toMatchObject({ kind: 'beforeunload' })
441+
const dialog = await pageDialog()
442+
await panelAction({ action: 'respond-dialog', requestId: dialog?.requestId, allowed: true })
443+
await expect
444+
.poll(() => shell.evaluate(() => (globalThis as Bridge).pageIssue))
445+
.toBe('load-error')
446+
await panelAction({ action: 'back' })
447+
await inPage(`setTimeout(() => { location.href = ${JSON.stringify(`${site}/next`)} })`)
448+
await expect.poll(pageUrl).toBe(`${site}/next`)
449+
expect(await pageDialog()).toBeNull()
450+
})
451+
271452
/** Opens a page-initiated confirm (no user gesture) on the tab at `path`. */
272453
const confirmFromPage = (path: string) =>
273454
shellApp.evaluate(({ webContents }, url) => {
@@ -306,6 +487,14 @@ test('page dialogs wait for the user on their page and stay automatic for the ag
306487
)
307488

308489
await check('a dialog while the browser is off screen stays automatic', async () => {
490+
const tabs = await shell.evaluate(
491+
async (scope) =>
492+
(await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs,
493+
SCOPE
494+
)
495+
const tabId = tabs.find((tab) => tab.url === `${site}/agent`)?.tabId
496+
expect(tabId).toBeTruthy()
497+
await panelAction({ action: 'switch-tab', tabId })
309498
await shell.evaluate((scope) => {
310499
const bridge = globalThis as Bridge
311500
window.clearInterval(bridge.boundsTimer)

‎apps/desktop/src/main/browser-agent/cdp.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function createOopifFrameFixture() {
6363

6464
/** Callbacks for a page with no user to ask, so the shell answers every dialog. */
6565
const shellAnswersDialogs = {
66-
offerToUser: () => false,
66+
claimUserDialog: () => false,
6767
onDialogClosed: () => {},
6868
claimUserLeave: () => false,
6969
}

‎apps/desktop/src/main/browser-agent/cdp.ts‎

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { createLogger } from '@sim/logger'
1313
import { getErrorMessage } from '@sim/utils/errors'
1414
import { interruptibleSleep } from '@sim/utils/helpers'
1515
import { isRecordLike } from '@sim/utils/object'
16+
import { truncateAtCodePoint } from '@sim/utils/string'
1617
import type { NativeImage, WebContents, WebFrameMain } from 'electron'
1718

1819
const logger = createLogger('BrowserAgentCdp')
@@ -38,19 +39,11 @@ export interface DialogResponse {
3839
export interface CdpCallbacks {
3940
/** A JS dialog was handled; the driver surfaces it to the model. */
4041
onDialog: (dialog: PageDialog) => void
41-
/** The running action's requested answer; dialogs are dismissed when it has none. */
42+
/** The running action's requested answer; defaults to declining the dialog. */
4243
dialogResponse: () => DialogResponse | null
43-
/**
44-
* Offers an alert or confirm to the user, who answers later through
45-
* `respond`. Returns false when the shell must answer it now instead.
46-
*/
47-
offerToUser: (
48-
kind: 'alert' | 'confirm',
49-
message: string,
50-
frameUrl: string,
51-
respond: (accept: boolean) => void
52-
) => boolean
53-
/** The page closed its dialog itself, by navigating away or crashing. */
44+
/** Leaves a visible user-owned alert or confirm to Electron's native dialog. */
45+
claimUserDialog: () => boolean
46+
/** The dialog ended through a user answer, a CDP answer, or page teardown. */
5447
onDialogClosed: () => void
5548
/** True when the user, not the shell, decides this beforeunload. */
5649
claimUserLeave: () => boolean
@@ -209,28 +202,18 @@ function handleDebuggerEvent(
209202
}
210203
if (method === 'Page.javascriptDialogOpening') {
211204
const type = String(params.type ?? 'dialog')
212-
const message = String(params.message ?? '').slice(0, 500)
205+
const rawMessage = String(params.message ?? '')
206+
const message = truncateAtCodePoint(rawMessage, 500, '')
213207
const requested = callbacks?.dialogResponse() ?? null
214-
// The user answers a dialog on the page they are using, unless an agent
215-
// action asked for a specific answer. Electron decides beforeunload itself
216-
// (will-prevent-unload), so that kind is only acknowledged here.
217-
if (!requested && (type === 'alert' || type === 'confirm')) {
218-
const frameUrl = typeof params.url === 'string' ? params.url : ''
219-
const offered = callbacks?.offerToUser(type, message, frameUrl, (accept) => {
220-
void answerDialog(contents, { accept }, parentSessionId).then((handled) => {
221-
if (!handled) logger.warn('Could not answer page dialog for the user', { type })
222-
})
223-
})
224-
if (offered) return
208+
if (!requested && (type === 'alert' || type === 'confirm') && callbacks?.claimUserDialog()) {
209+
return
225210
}
226211
if (type === 'beforeunload' && callbacks?.claimUserLeave()) {
227212
// The user's Leave replays the navigation; this unload stays cancelled.
228213
void answerDialog(contents, { accept: false }, parentSessionId)
229214
return
230215
}
231-
// Otherwise dialogs never stay open: beforeunload is accepted, and alert/confirm
232-
// follow the running action's requested answer, defaulting to dismissal so an
233-
// unexpected dialog can never block the page.
216+
// CDP unblocks JavaScript, but Electron may retain the native dialog until navigation.
234217
const accept = type === 'beforeunload' || requested?.accept === true
235218
void answerDialog(contents, { accept }, parentSessionId).then((handled) => {
236219
if (handled) logger.info('Handled page dialog', { type, accept })

0 commit comments

Comments
 (0)