Skip to content

Commit f728aa5

Browse files
feat(preview): give agents hover, select, drag, and click options
Agents could only left-click once, so hover menus, native dropdowns, context menus, double-click editing, and drag-and-drop needed evaluate hacks or were out of reach. preview_click takes button and clickCount on server and desktop tabs, and server tabs add preview_hover, preview_select (by value or visible label), and preview_drag. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 48cefe5 commit f728aa5

9 files changed

Lines changed: 313 additions & 28 deletions

File tree

‎apps/desktop/src/preview/Manager.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4166,7 +4166,15 @@ describe("PreviewManager", () => {
41664166
}
41674167
if (method === "Input.dispatchMouseEvent" && params?.type === "mousePressed") {
41684168
activity.push("mousePressed");
4169-
humanInput?.({}, { kind: "pointer", x: params.x, y: params.y, button: 0 });
4169+
humanInput?.(
4170+
{},
4171+
{
4172+
kind: "pointer",
4173+
x: params.x,
4174+
y: params.y,
4175+
button: { left: 0, middle: 1, right: 2 }[params.button as "left"],
4176+
},
4177+
);
41704178
}
41714179
return undefined;
41724180
});
@@ -4211,6 +4219,12 @@ describe("PreviewManager", () => {
42114219
activity.push(event.phase);
42124220
}),
42134221
);
4222+
const controllers: string[] = [];
4223+
yield* manager.subscribeStateChanges((_tabId, state) =>
4224+
Effect.sync(() => {
4225+
controllers.push(state.controller);
4226+
}),
4227+
);
42144228
yield* manager.createTab("tab_1");
42154229
yield* manager.registerWebview("tab_1", 42);
42164230
yield* manager.startRecording("tab_1");
@@ -4249,6 +4263,25 @@ describe("PreviewManager", () => {
42494263
button: "left",
42504264
clickCount: 1,
42514265
});
4266+
4267+
// A right double-click is the agent's own input, so it keeps control of the tab.
4268+
sendCommand.mockClear();
4269+
const doubleClick = yield* manager
4270+
.automationClick("tab_1", { x: 120, y: 80, button: "right", clickCount: 2 })
4271+
.pipe(Effect.forkChild({ startImmediately: true }));
4272+
yield* TestClock.adjust(200);
4273+
yield* Fiber.join(doubleClick);
4274+
expect(
4275+
sendCommand.mock.calls
4276+
.filter(([method]) => method === "Input.dispatchMouseEvent")
4277+
.map(([, params]) => [params?.type, params?.button, params?.clickCount]),
4278+
).toEqual([
4279+
["mousePressed", "right", 1],
4280+
["mouseReleased", "right", 1],
4281+
["mousePressed", "right", 2],
4282+
["mouseReleased", "right", 2],
4283+
]);
4284+
expect(controllers).not.toContain("human");
42524285
}),
42534286
),
42544287
);

‎apps/desktop/src/preview/Manager.ts‎

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,9 @@ const isPreviewInputSignal = (value: unknown): value is PreviewInputSignal => {
616616
);
617617
};
618618

619+
/** DOM `PointerEvent.button` for each CDP mouse button, matched against reported human input. */
620+
const POINTER_BUTTON_INDEX = { left: 0, middle: 1, right: 2 } as const;
621+
619622
const inputSignalsMatch = (left: PreviewInputSignal, right: PreviewInputSignal): boolean => {
620623
if (left.kind !== right.kind) return false;
621624
if (left.kind === "pointer" && right.kind === "pointer") {
@@ -3970,19 +3973,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
39703973
createdAt: clickCreatedAt,
39713974
});
39723975
yield* Effect.sleep(AGENT_CURSOR_CLICK_LEAD_MS);
3973-
yield* expectAgentInput(tabId, { kind: "pointer", ...point, button: 0 });
3974-
yield* send("Input.dispatchMouseEvent", {
3975-
type: "mousePressed",
3976-
...point,
3977-
button: "left",
3978-
clickCount: 1,
3979-
});
3980-
yield* send("Input.dispatchMouseEvent", {
3981-
type: "mouseReleased",
3982-
...point,
3983-
button: "left",
3984-
clickCount: 1,
3985-
});
3976+
const button = input.button ?? "left";
3977+
// A double-click is two press/release pairs whose clickCount climbs, as Chromium sends them.
3978+
for (let clickCount = 1; clickCount <= (input.clickCount ?? 1); clickCount += 1) {
3979+
yield* expectAgentInput(tabId, {
3980+
kind: "pointer",
3981+
...point,
3982+
button: POINTER_BUTTON_INDEX[button],
3983+
});
3984+
yield* send("Input.dispatchMouseEvent", {
3985+
type: "mousePressed",
3986+
...point,
3987+
button,
3988+
clickCount,
3989+
});
3990+
yield* send("Input.dispatchMouseEvent", {
3991+
type: "mouseReleased",
3992+
...point,
3993+
button,
3994+
clickCount,
3995+
});
3996+
}
39863997
});
39873998

39883999
const automationClick = Effect.fn("PreviewManager.automationClick")(function* (

‎apps/server/src/mcp/toolkits/preview/handlers.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
type PreviewAutomationOpenInput,
1414
type PreviewAutomationRecordingStatus,
1515
type PreviewAutomationResizeResult,
16+
type PreviewAutomationSelectResult,
1617
type PreviewAutomationSetColorSchemeResult,
1718
type PreviewAutomationSnapshot,
1819
type PreviewAutomationStatus,
@@ -205,6 +206,10 @@ const handlers = {
205206
},
206207
preview_click: (input) => invokeTargeted<object>("click", input, input.timeoutMs),
207208
preview_type: (input) => invokeTargeted<object>("type", input, input.timeoutMs),
209+
preview_hover: (input) => invokeTargeted<object>("hover", input, input.timeoutMs),
210+
preview_select: (input) =>
211+
invokeTargeted<PreviewAutomationSelectResult>("select", input, input.timeoutMs),
212+
preview_drag: (input) => invokeTargeted<object>("drag", input, input.timeoutMs),
208213
preview_upload: (input) => invokeTargeted<object>("upload", input, input.timeoutMs),
209214
preview_press: (input) => invokeTargeted<object>("press", input),
210215
preview_scroll: (input) => invokeTargeted<object>("scroll", input),

‎apps/server/src/mcp/toolkits/preview/tools.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ it("exports exact object result schemas for preview actions", () => {
6060
const actionNames = [
6161
"preview_click",
6262
"preview_type",
63+
"preview_hover",
64+
"preview_drag",
6365
"preview_upload",
6466
"preview_press",
6567
"preview_scroll",

‎apps/server/src/mcp/toolkits/preview/tools.ts‎

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import {
22
ToolActivityIcon,
33
PreviewAutomationClickInput,
44
PreviewAutomationDialogInput,
5+
PreviewAutomationDragInput,
56
PreviewAutomationError,
67
PreviewAutomationEvaluateInput,
8+
PreviewAutomationHoverInput,
79
PreviewAutomationNavigateInput,
810
PreviewAutomationOpenInput,
911
PreviewAutomationPressInput,
@@ -12,6 +14,8 @@ import {
1214
PreviewAutomationResizeInput,
1315
PreviewAutomationResizeResult,
1416
PreviewAutomationScrollInput,
17+
PreviewAutomationSelectInput,
18+
PreviewAutomationSelectResult,
1519
PreviewAutomationSetColorSchemeInput,
1620
PreviewAutomationSetColorSchemeResult,
1721
PreviewAutomationSnapshot,
@@ -157,7 +161,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool(
157161
const PreviewClickTool = browserTool(
158162
Tool.make("preview_click", {
159163
description:
160-
"Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together.",
164+
"Click exactly one target in the tab selected by tabId, or this agent session's current tab when omitted. Prefer a Playwright locator; selector accepts legacy CSS; x and y must be supplied together. Set button=right for a context menu or clickCount=2 for a double-click; server browser tabs only.",
161165
parameters: PreviewAutomationClickInput,
162166
success: PreviewActionResult,
163167
failure: PreviewAutomationError,
@@ -176,6 +180,42 @@ const PreviewTypeTool = browserTool(
176180
}).annotate(Tool.Title, "Type into preview page"),
177181
);
178182

183+
const PreviewHoverTool = safeBrowserTool(
184+
Tool.make("preview_hover", {
185+
description:
186+
"Move the mouse over exactly one target in the tab selected by tabId, or this agent session's current tab when omitted, to reveal hover menus and tooltips. Server browser tabs only.",
187+
parameters: PreviewAutomationHoverInput,
188+
success: PreviewActionResult,
189+
failure: PreviewAutomationError,
190+
dependencies,
191+
}).annotate(Tool.Title, "Hover preview page"),
192+
);
193+
194+
const PreviewSelectTool = browserTool(
195+
Tool.make("preview_select", {
196+
description:
197+
"Choose options in one native <select> in the tab selected by tabId, or this agent session's current tab when omitted, by option value or visible label. Custom dropdowns are not <select>; click them open and click the option instead. Server browser tabs only.",
198+
parameters: PreviewAutomationSelectInput,
199+
success: Schema.Struct({
200+
...PreviewAutomationSelectResult.fields,
201+
...presentationFields,
202+
}),
203+
failure: PreviewAutomationError,
204+
dependencies,
205+
}).annotate(Tool.Title, "Select preview option"),
206+
);
207+
208+
const PreviewDragTool = browserTool(
209+
Tool.make("preview_drag", {
210+
description:
211+
"Drag one element onto another in the tab selected by tabId, or this agent session's current tab when omitted. Server browser tabs only.",
212+
parameters: PreviewAutomationDragInput,
213+
success: PreviewActionResult,
214+
failure: PreviewAutomationError,
215+
dependencies,
216+
}).annotate(Tool.Title, "Drag in preview page"),
217+
);
218+
179219
const PreviewUploadTool = browserTool(
180220
Tool.make("preview_upload", {
181221
description:
@@ -275,6 +315,9 @@ export const PreviewToolkit = Toolkit.make(
275315
PreviewSnapshotTool,
276316
PreviewClickTool,
277317
PreviewTypeTool,
318+
PreviewHoverTool,
319+
PreviewSelectTool,
320+
PreviewDragTool,
278321
PreviewUploadTool,
279322
PreviewPressTool,
280323
PreviewScrollTool,
@@ -293,6 +336,9 @@ export const PreviewStandardToolkit = Toolkit.make(
293336
PreviewSetAppearanceTool,
294337
PreviewClickTool,
295338
PreviewTypeTool,
339+
PreviewHoverTool,
340+
PreviewSelectTool,
341+
PreviewDragTool,
296342
PreviewUploadTool,
297343
PreviewPressTool,
298344
PreviewScrollTool,

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,17 @@ import {
1111
type PreviewAutomationClickInput,
1212
type PreviewAutomationDialogInput,
1313
type PreviewAutomationConsoleEntry,
14+
type PreviewAutomationDragInput,
1415
type PreviewAutomationEvaluateInput,
16+
type PreviewAutomationHoverInput,
1517
type PreviewAutomationNavigateInput,
1618
type PreviewAutomationNetworkEntry,
1719
type PreviewAutomationOpenInput,
1820
type PreviewAutomationPressInput,
1921
type PreviewAutomationRequest,
2022
type PreviewAutomationResizeInput,
2123
type PreviewAutomationScrollInput,
24+
type PreviewAutomationSelectInput,
2225
type PreviewAutomationSetColorSchemeInput,
2326
type PreviewAutomationStatus,
2427
type PreviewAutomationTypeInput,
@@ -1462,6 +1465,18 @@ const make = Effect.gen(function* () {
14621465
.catch(constVoid);
14631466
return undefined;
14641467
}
1468+
case "hover":
1469+
return recordAction(tab, "hover", () =>
1470+
ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput),
1471+
);
1472+
case "select":
1473+
return recordAction(tab, "select", () =>
1474+
ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput),
1475+
);
1476+
case "drag":
1477+
return recordAction(tab, "drag", () =>
1478+
ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput),
1479+
);
14651480
case "upload":
14661481
return recordAction(tab, "upload", () =>
14671482
uploadFiles(tab, input as PreviewAutomationUploadInput),

‎apps/server/src/preview/ServerBrowserPage.test.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,4 +179,39 @@ describe("server browser element refs", () => {
179179
await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' });
180180
expect(await page.locator("button").textContent()).toBe("saved");
181181
});
182+
183+
it("right-clicks, double-clicks, hovers, selects, and drags like a pointer user", async () => {
184+
await page.setContent(`
185+
<style>#menu { position: absolute; display: none } #hover:hover + #menu { display: block }</style>
186+
<button id="target">target</button>
187+
<div id="hover">hover me</div><div id="menu">menu item</div>
188+
<select id="size"><option value="s">Small</option><option value="l">Large</option></select>
189+
<div id="card" draggable="true">card</div><div id="lane" style="height:40px">lane</div>
190+
<p id="log"></p>
191+
<script>
192+
const log = (text) => (document.getElementById("log").textContent += text + ";");
193+
const target = document.getElementById("target");
194+
target.addEventListener("contextmenu", (event) => { event.preventDefault(); log("context"); });
195+
target.addEventListener("dblclick", () => log("dblclick"));
196+
document.getElementById("card").addEventListener("dragstart", (event) =>
197+
event.dataTransfer.setData("text/plain", "card"),
198+
);
199+
for (const type of ["dragenter", "dragover"])
200+
document.getElementById("lane").addEventListener(type, (event) => event.preventDefault());
201+
document.getElementById("lane").addEventListener("drop", () => log("drop"));
202+
</script>`);
203+
await ServerBrowserPage.click(page, { locator: "#target", button: "right" });
204+
await ServerBrowserPage.click(page, { locator: "#target", clickCount: 2 });
205+
await ServerBrowserPage.hover(page, { locator: "#hover" });
206+
expect(await page.isVisible("#menu")).toBe(true);
207+
// A visible label selects the same option as its value.
208+
expect(await ServerBrowserPage.select(page, { locator: "#size", values: ["Large"] })).toEqual({
209+
selected: ["l"],
210+
});
211+
await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" });
212+
expect(await page.textContent("#log")).toBe("context;dblclick;drop;");
213+
await expect(
214+
ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }),
215+
).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" });
216+
});
182217
});

‎apps/server/src/preview/ServerBrowserPage.ts‎

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,14 @@ import {
44
PROVIDER_SEND_TURN_MAX_FILE_BYTES,
55
type PreviewAutomationClickInput,
66
type PreviewAutomationConsoleEntry,
7+
type PreviewAutomationDragInput,
78
type PreviewAutomationEvaluateInput,
9+
type PreviewAutomationHoverInput,
810
type PreviewAutomationNetworkEntry,
911
type PreviewAutomationPressInput,
1012
type PreviewAutomationScrollInput,
13+
type PreviewAutomationSelectInput,
14+
type PreviewAutomationSelectResult,
1115
type PreviewAutomationSnapshot,
1216
type PreviewAutomationTypeInput,
1317
type PreviewAutomationUploadInput,
@@ -216,8 +220,11 @@ export const click = async (
216220
const box = await locator.boundingBox({ timeout });
217221
if (box) point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
218222
}
223+
const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const;
219224
const clicked =
220-
locator === null ? page.mouse.click(point.x, point.y) : locator.click({ timeout });
225+
locator === null
226+
? page.mouse.click(point.x, point.y, options)
227+
: locator.click({ ...options, timeout });
221228
let onDialog = constVoid;
222229
const dialogOpened = new Promise<"dialog">((resolve) => {
223230
onDialog = () => resolve("dialog");
@@ -293,6 +300,58 @@ export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
293300
await page.keyboard.insertText(input.text);
294301
};
295302

303+
/** Hovering by locator moves the real mouse, so CSS :hover and pointer events both apply. */
304+
export const hover = async (page: Page, input: PreviewAutomationHoverInput) => {
305+
const locator = targetLocator(page, input);
306+
if (locator === null) {
307+
await page.mouse.move(input.x ?? 0, input.y ?? 0);
308+
return;
309+
}
310+
await locator.hover({ timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
311+
};
312+
313+
export const select = async (
314+
page: Page,
315+
input: PreviewAutomationSelectInput,
316+
): Promise<PreviewAutomationSelectResult> => {
317+
const locator = targetLocator(page, input)!;
318+
const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
319+
// Each entry matches an option by value first, then by its visible label.
320+
const options = await locator.evaluate(
321+
(element) =>
322+
element.tagName === "SELECT"
323+
? Array.from(
324+
(element as unknown as { options: ArrayLike<{ value: string; label: string }> })
325+
.options,
326+
({ value, label }) => ({ value, label: label.trim() }),
327+
)
328+
: null,
329+
undefined,
330+
{ timeout },
331+
);
332+
const values =
333+
options &&
334+
input.values.map(
335+
(entry) =>
336+
(
337+
options.find((option) => option.value === entry) ??
338+
options.find((option) => option.label === entry.trim())
339+
)?.value ?? entry,
340+
);
341+
if (values === null)
342+
throw new ServerBrowserOperationError(
343+
"PreviewAutomationTargetNotEditableError",
344+
"This element is not a <select>. Click a custom dropdown, then click its option.",
345+
);
346+
return { selected: await locator.selectOption(values, { timeout }) };
347+
};
348+
349+
export const drag = async (page: Page, input: PreviewAutomationDragInput) => {
350+
const source = targetLocator(page, { locator: input.source })!;
351+
const target = targetLocator(page, { locator: input.target })!;
352+
await source.dragTo(target, { timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
353+
};
354+
296355
/** Sets files on one file input; false when no locator or selector names one. */
297356
export const setInputFiles = async (page: Page, input: PreviewAutomationUploadInput) => {
298357
const locator = targetLocator(page, input);

0 commit comments

Comments
 (0)