Skip to content

Commit 75fcb2c

Browse files
committed
fix(desktop-browser): keep the chat caret while the agent works and route reload/back to the browser tab
1 parent bf66b16 commit 75fcb2c

13 files changed

Lines changed: 593 additions & 54 deletions

File tree

Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { createServer, type Server } from 'node:http'
3+
import { tmpdir } from 'node:os'
4+
import { dirname, join } from 'node:path'
5+
import { fileURLToPath } from 'node:url'
6+
import {
7+
type ElectronApplication,
8+
_electron as electron,
9+
expect,
10+
type Page,
11+
test,
12+
} from '@playwright/test'
13+
import type { BrowserToolName } from '@sim/browser-protocol'
14+
import type { SimDesktopApi } from '@sim/desktop-bridge'
15+
import { getErrorMessage } from '@sim/utils/errors'
16+
17+
const DESKTOP_DIR = fileURLToPath(new URL('..', import.meta.url))
18+
const SCOPE = 'browser-focus-e2e'
19+
const PRIMARY = process.platform === 'darwin' ? 'meta' : 'control'
20+
const SHELL_FIXTURE =
21+
'<!doctype html><title>Sim fixture</title><h1>Browser focus fixture</h1><textarea id="composer" aria-label="Composer"></textarea>'
22+
const page = (name: string) =>
23+
`<!doctype html><title>${name}</title><a href="/popup" target="_blank">Open popup</a><input aria-label="Field">`
24+
25+
type Bridge = typeof globalThis & { simDesktop: SimDesktopApi; shellMarker?: string }
26+
27+
/**
28+
* The browser shares one window with Sim, so focus and shortcuts decide whether
29+
* a keystroke lands in chat, in the page, or reloads all of Sim. These checks
30+
* drive the real shell and native tab views.
31+
*/
32+
test('browser focus and shortcuts stay with the surface the user is using', async () => {
33+
const reportPath =
34+
process.env.DESKTOP_BROWSER_FOCUS_REPORT_PATH ?? test.info().outputPath('browser-focus.json')
35+
const checks: {
36+
name: string
37+
status: 'passed' | 'failed'
38+
durationMs: number
39+
error?: string
40+
}[] = []
41+
const check = async (name: string, run: () => Promise<void>) => {
42+
const started = Date.now()
43+
try {
44+
await test.step(name, run)
45+
checks.push({ name, status: 'passed', durationMs: Date.now() - started })
46+
} catch (error) {
47+
checks.push({
48+
name,
49+
status: 'failed',
50+
durationMs: Date.now() - started,
51+
error: getErrorMessage(error),
52+
})
53+
throw error
54+
}
55+
}
56+
const calls = new Map<string, { chatId: string; toolName: BrowserToolName; args: unknown }>()
57+
const server: Server = createServer(async (request, response) => {
58+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
59+
if (path === '/api/desktop/tool/authorize') {
60+
let body = ''
61+
for await (const chunk of request) body += chunk.toString()
62+
const authorization = calls.get(JSON.parse(body).toolCallId)
63+
response.writeHead(authorization ? 200 : 403, { 'Content-Type': 'application/json' })
64+
response.end(JSON.stringify(authorization ?? {}))
65+
return
66+
}
67+
// The app origin serves the shell; the same server on localhost is the web.
68+
const isSite = request.headers.host?.startsWith('localhost') === true
69+
response.writeHead(200, {
70+
'Content-Type': 'text/html',
71+
...(isSite ? {} : { 'Set-Cookie': 'better-auth.session_token=fixture; HttpOnly; Path=/' }),
72+
})
73+
response.end(isSite ? page(path.slice(1)) : SHELL_FIXTURE)
74+
})
75+
const userData = mkdtempSync(join(tmpdir(), 'sim-browser-focus-e2e-'))
76+
let app: ElectronApplication | undefined
77+
let passed = false
78+
try {
79+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
80+
const address = server.address()
81+
if (!address || typeof address === 'string') throw new Error('Missing fixture address')
82+
const origin = `http://127.0.0.1:${address.port}`
83+
/** Pages outside the app origin browse in the agent partition, like any third-party site. */
84+
const site = origin.replace('127.0.0.1', 'localhost')
85+
const shellApp = await electron.launch({
86+
args: [process.env.SIM_DESKTOP_E2E_MAIN ?? '.'],
87+
cwd: DESKTOP_DIR,
88+
env: { ...process.env, SIM_DESKTOP_ORIGIN: origin, SIM_DESKTOP_USER_DATA: userData },
89+
})
90+
app = shellApp
91+
const shell: Page = await shellApp.firstWindow()
92+
await shellApp.evaluate(({ app, BrowserWindow }) => {
93+
const host = BrowserWindow.getAllWindows()[0]
94+
host.webContents.setBackgroundThrottling(false)
95+
app.focus({ steal: true })
96+
host.focus()
97+
})
98+
await expect(shell.getByRole('heading')).toHaveText('Browser focus fixture')
99+
await shell.evaluate(async (scope) => {
100+
const api = (globalThis as Bridge).simDesktop.browserAgent
101+
await api.activateScope(scope)
102+
const updateBounds = () =>
103+
api.setPanelBounds(
104+
{ x: 0, y: 120, width: innerWidth, height: innerHeight - 120 },
105+
null,
106+
scope
107+
)
108+
updateBounds()
109+
;(globalThis as Bridge & { boundsTimer?: number }).boundsTimer = window.setInterval(
110+
updateBounds,
111+
200
112+
)
113+
;(globalThis as Bridge).shellMarker = 'alive'
114+
}, SCOPE)
115+
116+
let callCount = 0
117+
const execute = async (tool: BrowserToolName, args: Record<string, unknown>) => {
118+
const callId = `browser-focus-${++callCount}`
119+
calls.set(callId, { chatId: SCOPE, toolName: tool, args })
120+
const result = await shell.evaluate(
121+
({ callId, tool, args, scope }) =>
122+
(globalThis as Bridge).simDesktop.browserAgent.executeTool(callId, tool, args, scope),
123+
{ callId, tool, args, scope: SCOPE }
124+
)
125+
expect(result.ok).toBe(true)
126+
return result.ok ? result.result : undefined
127+
}
128+
const panelAction = (action: Record<string, unknown>) =>
129+
shell.evaluate(
130+
({ action, scope }) =>
131+
(globalThis as Bridge).simDesktop.browserAgent.panelAction(
132+
action as unknown as Parameters<SimDesktopApi['browserAgent']['panelAction']>[0],
133+
scope
134+
),
135+
{ action, scope: SCOPE }
136+
)
137+
const setPanelFocused = (focused: boolean) =>
138+
shell.evaluate(
139+
({ focused, scope }) =>
140+
(globalThis as Bridge).simDesktop.browserAgent.setPanelFocused(focused, scope),
141+
{ focused, scope: SCOPE }
142+
)
143+
const tabCount = () =>
144+
shell.evaluate(
145+
async (scope) =>
146+
(await (globalThis as Bridge).simDesktop.browserAgent.activateScope(scope)).tabs.length,
147+
SCOPE
148+
)
149+
const shellAlive = () =>
150+
shell.evaluate(() => (globalThis as Bridge).shellMarker === 'alive').catch(() => false)
151+
const composerFocused = () =>
152+
shell.evaluate(() => document.hasFocus() && document.activeElement?.id === 'composer')
153+
const focusedPageUrls = () =>
154+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) =>
155+
BrowserWindow.getAllWindows()[0]
156+
.contentView.children.filter(
157+
(view) => view instanceof WebContentsView && view.webContents.isFocused()
158+
)
159+
.map((view) => (view as Electron.WebContentsView).webContents.getURL())
160+
)
161+
const visiblePageUrl = () =>
162+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
163+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
164+
(child) => child instanceof WebContentsView && child.getVisible()
165+
) as Electron.WebContentsView | undefined
166+
return view?.webContents.getURL() ?? null
167+
})
168+
/** Counts main-frame loads of the visible page from now on. */
169+
const countVisiblePageLoads = () =>
170+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
171+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
172+
(child) => child instanceof WebContentsView && child.getVisible()
173+
) as Electron.WebContentsView
174+
const counter = globalThis as typeof globalThis & { pageLoads?: number; counted?: boolean }
175+
counter.pageLoads = 0
176+
if (!counter.counted) {
177+
counter.counted = true
178+
view.webContents.on('did-finish-load', () => {
179+
counter.pageLoads = (counter.pageLoads ?? 0) + 1
180+
})
181+
}
182+
})
183+
const pageLoads = () =>
184+
shellApp.evaluate(() => (globalThis as typeof globalThis & { pageLoads?: number }).pageLoads)
185+
const focusVisiblePage = () =>
186+
shellApp.evaluate(({ BrowserWindow, WebContentsView }) => {
187+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
188+
(child) => child instanceof WebContentsView && child.getVisible()
189+
) as Electron.WebContentsView
190+
view.webContents.focus()
191+
})
192+
const pressInPage = (keyCode: string, modifiers: string[]) =>
193+
shellApp.evaluate(
194+
({ BrowserWindow, WebContentsView }, { keyCode, modifiers }) => {
195+
const view = BrowserWindow.getAllWindows()[0].contentView.children.find(
196+
(child) => child instanceof WebContentsView && child.getVisible()
197+
) as Electron.WebContentsView
198+
const input = { keyCode, modifiers } as Electron.KeyboardInputEvent
199+
view.webContents.sendInputEvent({ ...input, type: 'keyDown' })
200+
view.webContents.sendInputEvent({ ...input, type: 'keyUp' })
201+
},
202+
{ keyCode, modifiers }
203+
)
204+
/** Clicks an application-menu item the way its accelerator would. */
205+
const clickMenu = (label: string) =>
206+
shellApp.evaluate(({ BrowserWindow, Menu }, label) => {
207+
const win = BrowserWindow.getAllWindows()[0]
208+
const find = (items: Electron.MenuItem[]): Electron.MenuItem | null => {
209+
for (const item of items) {
210+
if (item.label === label) return item
211+
const found = item.submenu ? find(item.submenu.items) : null
212+
if (found) return found
213+
}
214+
return null
215+
}
216+
const item = find(Menu.getApplicationMenu()?.items ?? [])
217+
if (!item) throw new Error(`No menu item ${label}`)
218+
item.click(undefined, win, win.webContents)
219+
}, label)
220+
221+
await check('the agent working never takes the caret from the composer', async () => {
222+
await shell.locator('#composer').click()
223+
await shell.keyboard.type('draft')
224+
for (const [tool, args] of [
225+
['browser_open_url', { url: `${site}/one` }],
226+
['browser_navigate', { url: `${site}/two` }],
227+
['browser_open_tab', { url: `${site}/three` }],
228+
] as const) {
229+
await execute(tool, args)
230+
await expect.poll(visiblePageUrl).not.toBeNull()
231+
expect(await composerFocused()).toBe(true)
232+
expect(await focusedPageUrls()).toEqual([])
233+
}
234+
// A link without an opener is focused by Chromium itself as it is created.
235+
const snapshot = await execute('browser_snapshot', {})
236+
const ref = /"Open popup" \[ref=(\d+)\]/.exec(
237+
String((snapshot as { outline?: string }).outline)
238+
)?.[1]
239+
expect(ref, 'snapshot lists the popup link').toBeTruthy()
240+
await execute('browser_click', { elementId: Number(ref) })
241+
await expect.poll(tabCount).toBe(3)
242+
await expect.poll(focusedPageUrls).toEqual([])
243+
expect(await composerFocused()).toBe(true)
244+
await shell.keyboard.type(' continues')
245+
await expect(shell.locator('#composer')).toHaveValue('draft continues')
246+
})
247+
248+
await check('an omnibox navigation hands focus to the page it loads', async () => {
249+
await panelAction({ action: 'switch-tab', tabId: '1' })
250+
await panelAction({ action: 'navigate', url: `${site}/four` })
251+
await expect.poll(focusedPageUrls).toEqual([`${site}/four`])
252+
})
253+
254+
await check('reload keys typed in the page reload only that page', async () => {
255+
for (const [keyCode, modifiers] of [
256+
['R', [PRIMARY]],
257+
['R', [PRIMARY, 'shift']],
258+
['F5', []],
259+
] as const) {
260+
await countVisiblePageLoads()
261+
await focusVisiblePage()
262+
await pressInPage(keyCode, [...modifiers])
263+
await expect.poll(pageLoads).toBe(1)
264+
}
265+
})
266+
267+
await check('the page keeps its shortcut claim after the chrome reports blur', async () => {
268+
await setPanelFocused(true)
269+
await focusVisiblePage()
270+
await setPanelFocused(false)
271+
await countVisiblePageLoads()
272+
await clickMenu('Reload')
273+
await expect.poll(pageLoads).toBe(1)
274+
expect(await shellAlive()).toBe(true)
275+
})
276+
277+
await check('Back and Forward move through the focused page history', async () => {
278+
await panelAction({ action: 'navigate', url: `${site}/five` })
279+
await expect.poll(visiblePageUrl).toBe(`${site}/five`)
280+
await focusVisiblePage()
281+
await clickMenu('Back')
282+
await expect.poll(visiblePageUrl).toBe(`${site}/four`)
283+
await focusVisiblePage()
284+
await clickMenu('Forward')
285+
await expect.poll(visiblePageUrl).toBe(`${site}/five`)
286+
})
287+
288+
await check('browser shortcuts work while renderer chrome hides the page', async () => {
289+
const before = await tabCount()
290+
await shell.evaluate((scope) => {
291+
const bridge = globalThis as Bridge & { boundsTimer?: number }
292+
window.clearInterval(bridge.boundsTimer)
293+
bridge.simDesktop.browserAgent.setPanelBounds(null, null, scope)
294+
}, SCOPE)
295+
await setPanelFocused(true)
296+
await clickMenu('New Tab')
297+
await expect.poll(tabCount).toBe(before + 1)
298+
})
299+
passed = true
300+
} finally {
301+
mkdirSync(dirname(reportPath), { recursive: true })
302+
writeFileSync(reportPath, JSON.stringify({ passed, checks }, null, 2))
303+
await app?.close()
304+
await new Promise<void>((resolve) => server.close(() => resolve()))
305+
rmSync(userData, { recursive: true, force: true })
306+
}
307+
})

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5399,6 +5399,7 @@ export async function handlePanelAction(
53995399
)
54005400
session.prepareExplicitNavigation(contents)
54015401
void contents.loadURL(action.url).catch(() => {})
5402+
session.focusPageForUser(contents)
54025403
}
54035404
return
54045405
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export interface PanelHost {
4747
onViewDetached: (view: WebContentsView | null) => void
4848
/** Invalidates field-anchored UI when the page moves, hides, or detaches. */
4949
onGeometryChanged?: () => void
50+
/** Runs after each layout that leaves the active view attached and visible. */
51+
onViewShown?: (view: WebContentsView) => void
5052
}
5153

5254
let host: PanelHost = {
@@ -549,6 +551,7 @@ export function layout(): void {
549551
}, 1_000)
550552
}
551553
}
554+
if (visible) host.onViewShown?.(active.view)
552555
}
553556

554557
/** Converts the applied native DIP rectangle back into Sim viewport CSS pixels. */

0 commit comments

Comments
 (0)