Skip to content

Commit b03c4a3

Browse files
committed
fix(browser): cancel drags during their settle and keep parked agent tabs in the main window
- An already-aborted drag sends no input, and an abort during the settle hold takes the cancellation cleanup instead of dropping. - A coordinate hover re-checks the active tab right before it moves. - Parking an agent tab hands keyboard focus back to whichever of the Sim renderer or the visible page held it. - An agent tab detached from a secondary window is parked in the main window, which outlives it. - A framed target covered by a parent-page overlay lists that overlay's controls.
1 parent 27d73bc commit b03c4a3

6 files changed

Lines changed: 87 additions & 6 deletions

File tree

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,41 @@ describe('browser-agent CDP instrumentation', () => {
294294
}
295295
})
296296

297+
it('sends nothing for an already-aborted drag and cancels one aborted while it settles', async () => {
298+
const contents = new WebContentsView().webContents
299+
const mouse = () =>
300+
vi
301+
.mocked(contents.debugger.sendCommand)
302+
.mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent')
303+
.map(([, params]) => toRecord(params).type)
304+
const aborted = new AbortController()
305+
aborted.abort()
306+
await expect(
307+
dragPointer(contents, { x: 0, y: 0 }, { x: 50, y: 0 }, undefined, aborted.signal)
308+
).rejects.toMatchObject({ name: 'AbortError' })
309+
expect(contents.debugger.sendCommand).not.toHaveBeenCalled()
310+
311+
vi.useFakeTimers()
312+
try {
313+
const controller = new AbortController()
314+
const drag = dragPointer(
315+
contents,
316+
{ x: 0, y: 0 },
317+
{ x: 50, y: 0 },
318+
undefined,
319+
controller.signal
320+
)
321+
const settled = expect(drag).rejects.toMatchObject({ name: 'AbortError' })
322+
// The default route takes 13 moves 20 ms apart, then a 120 ms settle hold.
323+
await vi.advanceTimersByTimeAsync(300)
324+
controller.abort()
325+
await settled
326+
expect(mouse().at(-1)).toBe('mouseReleased')
327+
} finally {
328+
vi.useRealTimers()
329+
}
330+
})
331+
297332
it('releases the button when a timed drag is aborted mid-route', async () => {
298333
const contents = new WebContentsView().webContents
299334
const types = () =>

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import type { BrowserTheme } from '@sim/browser-protocol'
1212
import { createLogger } from '@sim/logger'
1313
import { getErrorMessage } from '@sim/utils/errors'
14-
import { interruptibleSleep, sleep } from '@sim/utils/helpers'
14+
import { interruptibleSleep } from '@sim/utils/helpers'
1515
import { isRecordLike } from '@sim/utils/object'
1616
import type { NativeImage, WebContents, WebFrameMain } from 'electron'
1717

@@ -1165,6 +1165,7 @@ export async function dragPointer(
11651165
path: PointerPath = DIRECT_PATH,
11661166
signal?: AbortSignal
11671167
): Promise<{ nativeDragIntercepted: boolean }> {
1168+
signal?.throwIfAborted()
11681169
const { points, stepDelayMs } = pointerPathSteps(from, path, to)
11691170
const interception: DragInterception = { intercepted: false, data: null }
11701171
dragInterceptionsByContents.set(contents, interception)
@@ -1221,7 +1222,8 @@ export async function dragPointer(
12211222
signal?.throwIfAborted()
12221223
// Hold over the target so drop zones running enter/over animations settle
12231224
// before the release lands.
1224-
await sleep(120)
1225+
await interruptibleSleep(120, signal)
1226+
signal?.throwIfAborted()
12251227
if (interception.intercepted && interception.data) {
12261228
await sendInput(contents, 'Input.dispatchDragEvent', {
12271229
type: 'drop',

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4528,6 +4528,7 @@ async function executeToolInner(
45284528
const beforePage = await pageActionState(contents, true)
45294529
const beforeElement = await activeElementState(contents)
45304530
assertCurrentExecution()
4531+
assertActiveContents(contents)
45314532
await cdp.movePointer(contents, path, { x, y }, signal)
45324533
await sleep(150)
45334534
const afterElement = await activeElementState(contents)

‎apps/desktop/src/main/browser-agent/page-functions.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1482,7 +1482,11 @@ export function clickElement(
14821482
if (!scrollToTarget) {
14831483
return clickElement(id, dispatchSynthetic, focusForKeyboard, allowDisabled, true)
14841484
}
1485-
return { error: 'obstructed', blocker: blockerLabel(parentHit) }
1485+
return {
1486+
error: 'obstructed',
1487+
blocker: blockerLabel(parentHit),
1488+
blockerControls: window.__simAgentOverlayControls(parentHit),
1489+
}
14861490
}
14871491
}
14881492
ownerView = frame.ownerDocument.defaultView

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,8 @@ function detachAttachedView(): void {
315315
if (!view || !win) return
316316
try {
317317
if (win.isDestroyed() || view.webContents.isDestroyed()) return
318-
if (isAgentView(view)) {
318+
// An agent view stays in the main window, which outlives any secondary window.
319+
if (isAgentView(view) && win === host.getMainWindow()) {
319320
view.setVisible(false)
320321
parkedViews.set(view, win)
321322
return
@@ -326,6 +327,7 @@ function detachAttachedView(): void {
326327
error: getErrorMessage(error, 'unknown'),
327328
})
328329
}
330+
if (isAgentView(view)) parkAgentViews()
329331
}
330332

331333
/** Reveals the native view and invalidates every frame captured for its old state. */
@@ -399,7 +401,11 @@ function parkAgentViews(): void {
399401
if (view === attachedView || view.webContents.isDestroyed()) continue
400402
const parkedIn = parkedViews.get(view)
401403
if (parkedIn && !parkedIn.isDestroyed()) continue
402-
const rendererHadFocus = !win.webContents.isDestroyed() && win.webContents.isFocused()
404+
// addChildView hands keyboard focus to the parked view; give it back to whichever of the
405+
// Sim renderer or the visible browser page held it.
406+
const focused = [win.webContents, attachedView?.webContents].find(
407+
(contents) => contents && !contents.isDestroyed() && contents.isFocused()
408+
)
403409
try {
404410
view.setVisible(false)
405411
win.contentView.addChildView(view)
@@ -409,7 +415,7 @@ function parkAgentViews(): void {
409415
error: getErrorMessage(error, 'unknown'),
410416
})
411417
}
412-
if (rendererHadFocus) win.webContents.focus()
418+
focused?.focus()
413419
}
414420
}
415421

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2503,6 +2503,39 @@ describe('browser-agent session', () => {
25032503
expect(content.removeChildView).toHaveBeenCalledWith(second.view)
25042504
})
25052505

2506+
it('gives keyboard focus back to the visible page when it parks the agent tab', () => {
2507+
const visibleTab = session.ensureTab()
2508+
panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 })
2509+
const visibleContents = visibleTab.view.webContents as unknown as MockView['webContents']
2510+
visibleContents.isFocused.mockReturnValue(true)
2511+
vi.mocked(visibleContents.focus).mockClear()
2512+
2513+
session.addAutomationTab()
2514+
2515+
expect(visibleContents.focus).toHaveBeenCalled()
2516+
})
2517+
2518+
it('parks an agent tab in the main window when a secondary window stops showing it', () => {
2519+
const otherWindow = mainWindowMock() as unknown as {
2520+
contentView: {
2521+
addChildView: ReturnType<typeof vi.fn>
2522+
removeChildView: ReturnType<typeof vi.fn>
2523+
}
2524+
}
2525+
const agentTab = session.ensureTab()
2526+
panel.setPanelBounds(
2527+
{ x: 0, y: 0, width: 800, height: 600 },
2528+
otherWindow as unknown as BrowserWindow
2529+
)
2530+
expect(otherWindow.contentView.addChildView).toHaveBeenCalledWith(agentTab.view)
2531+
vi.mocked(win.contentView.addChildView).mockClear()
2532+
2533+
session.addTab()
2534+
2535+
expect(otherWindow.contentView.removeChildView).toHaveBeenCalledWith(agentTab.view)
2536+
expect(win.contentView.addChildView).toHaveBeenCalledWith(agentTab.view)
2537+
})
2538+
25062539
it('keeps the agent tab composited while the user views another tab, and releases it on close', () => {
25072540
const agentTab = session.ensureTab()
25082541
panel.setPanelBounds({ x: 0, y: 0, width: 800, height: 600 })

0 commit comments

Comments
 (0)