Skip to content

Commit e6badaf

Browse files
authored
fix(browser): composite background agent tabs, actionable refusals, timed pointer paths (#8291)
* fix(browser): keep the agent tab composited when no panel shows it A tab view that is in no window has no compositor surface, so CDP mouse input on it never acknowledges and screenshots never capture. The agent's tab was left that way whenever the user viewed another tab or had not opened the browser panel: clicks failed or stalled five seconds and screenshots failed. Each chat's agent tab is now parked invisibly in the main window, adopted in place when the panel shows it, and released on every tab teardown path. * improvement(browser): name the controls a refused click can use next A click refused because an overlay covers the target now lists the overlay's own snapshot controls, so the agent dismisses it and retries the same id without another snapshot. A click that lands on a nested control names that control's id. Only real overlays (dialogs, modals, fixed or sticky layers) list controls. * feat(browser): route drags and coordinate hovers along timed pointer paths browser_drag takes optional via points and a durationMs, and browser_hover can target a viewport point with the same route, moving with no button pressed. Pages that react to pointer speed or position (sliders, physics, canvas hover, cursor-following games) can now be driven the way Claude computer use and OpenAI's CUA allow. A plain drag keeps its exact previous pace; batches refuse timed paths so they cannot outlast the batch watchdog. * fix(browser): honor cancellation on pointer routes and keep agent tabs tracked across scopes - Timed drags and hover routes stop as soon as their tool call is cancelled; a drag still releases the button through its existing cleanup. - A coordinate hover refuses durationMs without via points instead of silently moving straight to the target. - A scope migration carries the chat's agent tab registration with it, so the migrated chat's old agent tab is released when it switches tabs. - Overlay controls are found through shadow roots and with any tag casing. * 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. * fix(browser): refuse a coordinate hover across a navigation The coordinate hover captures the navigation epoch before its page probes and rejects a cross-document navigation before moving the pointer, as browser_click_at and browser_drag do. The overlay helper skips the empty registry slots earlier snapshots leave.
1 parent a472287 commit e6badaf

11 files changed

Lines changed: 965 additions & 61 deletions

File tree

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

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,13 @@ import {
1515
captureScreenshot,
1616
clickAt,
1717
consumeAgentContextMenu,
18+
dragPointer,
1819
ensureInstrumented,
1920
evaluateInIsolatedFrame,
2021
insertText,
22+
movePointer,
2123
PRIMARY_CLICK,
24+
pointerPathSteps,
2225
releaseFileInput,
2326
resolveFileInput,
2427
setColorScheme,
@@ -258,6 +261,122 @@ describe('browser-agent CDP instrumentation', () => {
258261
])
259262
})
260263

264+
it('stops a pointer route as soon as it is aborted', async () => {
265+
const contents = new WebContentsView().webContents
266+
const moves = () =>
267+
vi
268+
.mocked(contents.debugger.sendCommand)
269+
.mock.calls.filter(([method]) => String(method).startsWith('Input.dispatchMouseEvent'))
270+
const aborted = new AbortController()
271+
aborted.abort()
272+
await expect(
273+
movePointer(contents, { via: [], durationMs: null }, { x: 5, y: 5 }, aborted.signal)
274+
).rejects.toMatchObject({ name: 'AbortError' })
275+
expect(moves()).toHaveLength(0)
276+
277+
vi.useFakeTimers()
278+
try {
279+
const controller = new AbortController()
280+
const route = movePointer(
281+
contents,
282+
{ via: [{ x: 0, y: 0 }], durationMs: 5_000 },
283+
{ x: 500, y: 0 },
284+
controller.signal
285+
)
286+
const settled = expect(route).rejects.toMatchObject({ name: 'AbortError' })
287+
await vi.advanceTimersByTimeAsync(100)
288+
const sentBeforeAbort = moves().length
289+
controller.abort()
290+
await settled
291+
expect(moves()).toHaveLength(sentBeforeAbort)
292+
} finally {
293+
vi.useRealTimers()
294+
}
295+
})
296+
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+
332+
it('releases the button when a timed drag is aborted mid-route', async () => {
333+
const contents = new WebContentsView().webContents
334+
const types = () =>
335+
vi
336+
.mocked(contents.debugger.sendCommand)
337+
.mock.calls.filter(([method]) => method === 'Input.dispatchMouseEvent')
338+
.map(([, params]) => toRecord(params).type)
339+
vi.useFakeTimers()
340+
try {
341+
const controller = new AbortController()
342+
const drag = dragPointer(
343+
contents,
344+
{ x: 0, y: 0 },
345+
{ x: 500, y: 0 },
346+
{ via: [], durationMs: 5_000 },
347+
controller.signal
348+
)
349+
const settled = expect(drag).rejects.toMatchObject({ name: 'AbortError' })
350+
await vi.advanceTimersByTimeAsync(100)
351+
controller.abort()
352+
await settled
353+
expect(types().at(-1)).toBe('mouseReleased')
354+
expect(types().filter((type) => type === 'mouseMoved').length).toBeLessThan(20)
355+
} finally {
356+
vi.useRealTimers()
357+
}
358+
})
359+
360+
it('keeps the default drag pace and lands exactly on every via point', () => {
361+
const direct = pointerPathSteps({ x: 0, y: 0 }, { via: [], durationMs: null }, { x: 120, y: 0 })
362+
expect(direct.stepDelayMs).toBe(20)
363+
expect(direct.points).toHaveLength(12)
364+
expect(direct.points[0]).toEqual({ x: 10, y: 0 })
365+
expect(direct.points[11]).toEqual({ x: 120, y: 0 })
366+
367+
const routed = pointerPathSteps(
368+
{ x: 0, y: 0 },
369+
{ via: [{ x: 100, y: 0 }], durationMs: 800 },
370+
{ x: 100, y: 300 }
371+
)
372+
expect(routed.points).toContainEqual({ x: 100, y: 0 })
373+
expect(routed.points[routed.points.length - 1]).toEqual({ x: 100, y: 300 })
374+
expect(routed.points.length * routed.stepDelayMs).toBeCloseTo(800)
375+
// The longer second segment gets about three times the steps of the first.
376+
const corner = routed.points.findIndex((point) => point.x === 100 && point.y === 0)
377+
expect(routed.points.length - 1 - corner).toBeGreaterThan(corner * 2)
378+
})
379+
261380
it('holds the button down for holdMs before releasing it', async () => {
262381
const contents = new WebContentsView().webContents
263382
const types = () =>

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

Lines changed: 102 additions & 13 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

@@ -948,6 +948,91 @@ export async function moveMouse(contents: WebContents, x: number, y: number): Pr
948948
})
949949
}
950950

951+
/** A point in CSS viewport pixels. */
952+
export interface ViewportPoint {
953+
x: number
954+
y: number
955+
}
956+
957+
/** The points a pointer passes through on its way, and how long the whole movement takes. */
958+
export interface PointerPath {
959+
via: ViewportPoint[]
960+
/** Total movement time; null keeps the default brisk pace. */
961+
durationMs: number | null
962+
}
963+
964+
export const DIRECT_PATH: PointerPath = { via: [], durationMs: null }
965+
966+
const DEFAULT_PATH_STEPS = 12
967+
const DEFAULT_PATH_STEP_MS = 20
968+
/** One display frame, so a timed movement looks continuous to animation-driven pages. */
969+
const TIMED_PATH_STEP_MS = 16
970+
971+
/**
972+
* The moves from `from` through `path.via` to `to`, and the pause after each. A direct path
973+
* keeps the default 12 moves 20 ms apart. A path with via points or a duration moves one frame
974+
* at a time, shares the steps across segments by length, and lands exactly on every via point.
975+
*/
976+
export function pointerPathSteps(
977+
from: ViewportPoint,
978+
path: PointerPath,
979+
to: ViewportPoint
980+
): { points: ViewportPoint[]; stepDelayMs: number } {
981+
const lerp = (a: ViewportPoint, b: ViewportPoint, t: number): ViewportPoint => ({
982+
x: a.x + (b.x - a.x) * t,
983+
y: a.y + (b.y - a.y) * t,
984+
})
985+
if (path.via.length === 0 && path.durationMs === null) {
986+
const points: ViewportPoint[] = []
987+
for (let step = 1; step <= DEFAULT_PATH_STEPS; step++) {
988+
points.push(lerp(from, to, step / DEFAULT_PATH_STEPS))
989+
}
990+
return { points, stepDelayMs: DEFAULT_PATH_STEP_MS }
991+
}
992+
const vertices = [from, ...path.via, to]
993+
const durationMs = path.durationMs ?? DEFAULT_PATH_STEPS * DEFAULT_PATH_STEP_MS
994+
const lengths = vertices
995+
.slice(1)
996+
.map((vertex, index) => Math.hypot(vertex.x - vertices[index].x, vertex.y - vertices[index].y))
997+
const totalLength = lengths.reduce((sum, length) => sum + length, 0)
998+
const totalSteps = Math.max(lengths.length, Math.round(durationMs / TIMED_PATH_STEP_MS))
999+
const points: ViewportPoint[] = []
1000+
lengths.forEach((length, index) => {
1001+
const share = totalLength > 0 ? length / totalLength : 1 / lengths.length
1002+
const segmentSteps = Math.max(1, Math.round(totalSteps * share))
1003+
for (let step = 1; step <= segmentSteps; step++) {
1004+
points.push(lerp(vertices[index], vertices[index + 1], step / segmentSteps))
1005+
}
1006+
})
1007+
return { points, stepDelayMs: durationMs / points.length }
1008+
}
1009+
1010+
/**
1011+
* Moves the pointer with no button pressed through `path.via` to `to`, starting from the first
1012+
* via point (or `to` itself for a direct move), for hover effects that follow the cursor.
1013+
*/
1014+
export async function movePointer(
1015+
contents: WebContents,
1016+
path: PointerPath,
1017+
to: ViewportPoint,
1018+
signal?: AbortSignal
1019+
): Promise<void> {
1020+
const [start, ...rest] = [...path.via, to]
1021+
signal?.throwIfAborted()
1022+
await moveMouse(contents, start.x, start.y)
1023+
if (rest.length === 0) return
1024+
const { points, stepDelayMs } = pointerPathSteps(
1025+
start,
1026+
{ via: rest.slice(0, -1), durationMs: path.durationMs },
1027+
to
1028+
)
1029+
for (const point of points) {
1030+
await interruptibleSleep(stepDelayMs, signal)
1031+
signal?.throwIfAborted()
1032+
await moveMouse(contents, point.x, point.y)
1033+
}
1034+
}
1035+
9511036
/** One trusted click gesture: which button, how many presses, and held modifiers. */
9521037
export interface PointerClick {
9531038
button: 'left' | 'right' | 'middle'
@@ -1075,11 +1160,13 @@ export async function clickAt(
10751160
*/
10761161
export async function dragPointer(
10771162
contents: WebContents,
1078-
from: { x: number; y: number },
1079-
to: { x: number; y: number },
1080-
steps = 12,
1081-
stepDelayMs = 20
1163+
from: ViewportPoint,
1164+
to: ViewportPoint,
1165+
path: PointerPath = DIRECT_PATH,
1166+
signal?: AbortSignal
10821167
): Promise<{ nativeDragIntercepted: boolean }> {
1168+
signal?.throwIfAborted()
1169+
const { points, stepDelayMs } = pointerPathSteps(from, path, to)
10831170
const interception: DragInterception = { intercepted: false, data: null }
10841171
dragInterceptionsByContents.set(contents, interception)
10851172
let interceptEnabled = false
@@ -1124,17 +1211,19 @@ export async function dragPointer(
11241211
})
11251212
// Small first nudge so libraries with a start threshold (commonly 3-8px)
11261213
// register the drag before the pointer sweeps across the page.
1127-
await dragMove(from.x + Math.sign(to.x - from.x || 1) * 4, from.y + 2)
1128-
await sleep(stepDelayMs)
1129-
const stepCount = Math.max(2, steps)
1130-
for (let step = 1; step <= stepCount; step++) {
1131-
const progress = step / stepCount
1132-
await dragMove(from.x + (to.x - from.x) * progress, from.y + (to.y - from.y) * progress)
1133-
await sleep(stepDelayMs)
1214+
const heading = points[0] ?? to
1215+
await dragMove(from.x + Math.sign(heading.x - from.x || 1) * 4, from.y + 2)
1216+
await interruptibleSleep(stepDelayMs, signal)
1217+
for (const point of points) {
1218+
signal?.throwIfAborted()
1219+
await dragMove(point.x, point.y)
1220+
await interruptibleSleep(stepDelayMs, signal)
11341221
}
1222+
signal?.throwIfAborted()
11351223
// Hold over the target so drop zones running enter/over animations settle
11361224
// before the release lands.
1137-
await sleep(120)
1225+
await interruptibleSleep(120, signal)
1226+
signal?.throwIfAborted()
11381227
if (interception.intercepted && interception.data) {
11391228
await sendInput(contents, 'Input.dispatchDragEvent', {
11401229
type: 'drop',

0 commit comments

Comments
 (0)