Skip to content

Commit f4b7dd4

Browse files
committed
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.
1 parent 7b7f36e commit f4b7dd4

6 files changed

Lines changed: 399 additions & 16 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
evaluateInIsolatedFrame,
2020
insertText,
2121
PRIMARY_CLICK,
22+
pointerPathSteps,
2223
releaseFileInput,
2324
resolveFileInput,
2425
setColorScheme,
@@ -258,6 +259,26 @@ describe('browser-agent CDP instrumentation', () => {
258259
])
259260
})
260261

262+
it('keeps the default drag pace and lands exactly on every via point', () => {
263+
const direct = pointerPathSteps({ x: 0, y: 0 }, { via: [], durationMs: null }, { x: 120, y: 0 })
264+
expect(direct.stepDelayMs).toBe(20)
265+
expect(direct.points).toHaveLength(12)
266+
expect(direct.points[0]).toEqual({ x: 10, y: 0 })
267+
expect(direct.points[11]).toEqual({ x: 120, y: 0 })
268+
269+
const routed = pointerPathSteps(
270+
{ x: 0, y: 0 },
271+
{ via: [{ x: 100, y: 0 }], durationMs: 800 },
272+
{ x: 100, y: 300 }
273+
)
274+
expect(routed.points).toContainEqual({ x: 100, y: 0 })
275+
expect(routed.points[routed.points.length - 1]).toEqual({ x: 100, y: 300 })
276+
expect(routed.points.length * routed.stepDelayMs).toBeCloseTo(800)
277+
// The longer second segment gets about three times the steps of the first.
278+
const corner = routed.points.findIndex((point) => point.x === 100 && point.y === 0)
279+
expect(routed.points.length - 1 - corner).toBeGreaterThan(corner * 2)
280+
})
281+
261282
it('holds the button down for holdMs before releasing it', async () => {
262283
const contents = new WebContentsView().webContents
263284
const types = () =>

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

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,90 @@ 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+
await moveMouse(contents, start.x, start.y)
1022+
if (rest.length === 0) return
1023+
const { points, stepDelayMs } = pointerPathSteps(
1024+
start,
1025+
{ via: rest.slice(0, -1), durationMs: path.durationMs },
1026+
to
1027+
)
1028+
for (const point of points) {
1029+
signal?.throwIfAborted()
1030+
await interruptibleSleep(stepDelayMs, signal)
1031+
await moveMouse(contents, point.x, point.y)
1032+
}
1033+
}
1034+
9511035
/** One trusted click gesture: which button, how many presses, and held modifiers. */
9521036
export interface PointerClick {
9531037
button: 'left' | 'right' | 'middle'
@@ -1075,11 +1159,11 @@ export async function clickAt(
10751159
*/
10761160
export async function dragPointer(
10771161
contents: WebContents,
1078-
from: { x: number; y: number },
1079-
to: { x: number; y: number },
1080-
steps = 12,
1081-
stepDelayMs = 20
1162+
from: ViewportPoint,
1163+
to: ViewportPoint,
1164+
path: PointerPath = DIRECT_PATH
10821165
): Promise<{ nativeDragIntercepted: boolean }> {
1166+
const { points, stepDelayMs } = pointerPathSteps(from, path, to)
10831167
const interception: DragInterception = { intercepted: false, data: null }
10841168
dragInterceptionsByContents.set(contents, interception)
10851169
let interceptEnabled = false
@@ -1124,12 +1208,11 @@ export async function dragPointer(
11241208
})
11251209
// Small first nudge so libraries with a start threshold (commonly 3-8px)
11261210
// 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)
1211+
const heading = points[0] ?? to
1212+
await dragMove(from.x + Math.sign(heading.x - from.x || 1) * 4, from.y + 2)
11281213
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)
1214+
for (const point of points) {
1215+
await dragMove(point.x, point.y)
11331216
await sleep(stepDelayMs)
11341217
}
11351218
// Hold over the target so drop zones running enter/over animations settle

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4623,6 +4623,98 @@ describe('credential protection', () => {
46234623
expect(cdpCalls(contents, 'Input.setInterceptDrags').length).toBeGreaterThan(0)
46244624
})
46254625

4626+
it('drags through via points at the requested pace', async () => {
4627+
const contents = await openPage()
4628+
respondWith(contents, {
4629+
describePointTarget: { found: true, element: 'div "Card"' },
4630+
readActiveElementState: {},
4631+
readPageActionState: {},
4632+
})
4633+
4634+
const result = await driver.executeTool('chat-test', 'browser_drag', {
4635+
fromX: 40,
4636+
fromY: 50,
4637+
toX: 200,
4638+
toY: 260,
4639+
via: [{ x: 300, y: 50 }],
4640+
durationMs: 320,
4641+
})
4642+
4643+
expect(result).toMatchObject({ ok: true, result: { dispatched: true } })
4644+
const moves = cdpCalls(contents, 'Input.dispatchMouseEvent')
4645+
.map(([, event]) => event as { type?: string; x?: number; y?: number; buttons?: number })
4646+
.filter((event) => event.type === 'mouseMoved' && event.buttons === 1)
4647+
expect(moves.some((event) => event.x === 300 && event.y === 50)).toBe(true)
4648+
expect(moves.length).toBeGreaterThanOrEqual(20)
4649+
})
4650+
4651+
it('moves the pointer along a coordinate hover path with no button pressed', async () => {
4652+
const contents = await openPage()
4653+
respondWith(contents, {
4654+
describePointTarget: { found: true, element: 'canvas "Field"' },
4655+
readActiveElementState: {},
4656+
readPageActionState: {},
4657+
})
4658+
4659+
const result = await driver.executeTool('chat-test', 'browser_hover', {
4660+
x: 400,
4661+
y: 300,
4662+
via: [
4663+
{ x: 100, y: 300 },
4664+
{ x: 250, y: 150 },
4665+
],
4666+
})
4667+
4668+
expect(result).toMatchObject({ ok: true, result: { hovered: true, x: 400, y: 300 } })
4669+
const events = cdpCalls(contents, 'Input.dispatchMouseEvent').map(
4670+
([, event]) => event as { type?: string; x?: number; y?: number; button?: string }
4671+
)
4672+
expect(events.every((event) => event.type === 'mouseMoved' && event.button === 'none')).toBe(
4673+
true
4674+
)
4675+
expect(events[0]).toMatchObject({ x: 100, y: 300 })
4676+
expect(events.some((event) => event.x === 250 && event.y === 150)).toBe(true)
4677+
expect(events[events.length - 1]).toMatchObject({ x: 400, y: 300 })
4678+
})
4679+
4680+
it('rejects pointer paths where they cannot apply', async () => {
4681+
await openPage()
4682+
4683+
const elementHover = await driver.executeTool('chat-test', 'browser_hover', {
4684+
elementId: 0,
4685+
via: [{ x: 1, y: 2 }],
4686+
})
4687+
const badPoint = await driver.executeTool('chat-test', 'browser_hover', {
4688+
x: 10,
4689+
y: 10,
4690+
via: [{ x: 'left', y: 2 }],
4691+
})
4692+
const tooLong = await driver.executeTool('chat-test', 'browser_drag', {
4693+
fromX: 1,
4694+
fromY: 1,
4695+
toX: 50,
4696+
toY: 50,
4697+
durationMs: 10_001,
4698+
})
4699+
const batched = await driver.executeTool('chat-test', 'browser_batch', {
4700+
actions: [
4701+
{ tool: 'browser_click', args: { elementId: 0 } },
4702+
{ tool: 'browser_hover', args: { x: 5, y: 5, durationMs: 500 } },
4703+
],
4704+
})
4705+
4706+
expect(elementHover).toMatchObject({
4707+
ok: false,
4708+
error: expect.stringContaining('coordinate hover'),
4709+
})
4710+
expect(badPoint).toMatchObject({ ok: false, error: expect.stringContaining('via point') })
4711+
expect(tooLong).toMatchObject({ ok: false, error: expect.stringContaining('durationMs') })
4712+
expect(batched).toMatchObject({
4713+
ok: false,
4714+
error: expect.stringContaining('timed pointer path'),
4715+
})
4716+
})
4717+
46264718
it('drags from a snapshot element to a coordinate target', async () => {
46274719
const contents = await openPage()
46284720
respondWith(contents, {

0 commit comments

Comments
 (0)