Skip to content

Commit d6a2d3c

Browse files
authored
fix(computer): emit one full WHEEL_DELTA per libnut scroll tick (#2575)
* fix(computer): anchor untargeted libnut scrolls at viewport center * style(computer): format scroll target helper * fix(computer): focus active window before untargeted Windows scroll * test(report): stabilize theme toggle e2e checks * test(report): stabilize detail panel e2e checks * test(report): restore Midscene e2e dogfooding * test(report): simplify dogfood e2e prompts * test(report): align detail panel dogfood assertion
1 parent 4f02d2f commit d6a2d3c

9 files changed

Lines changed: 408 additions & 45 deletions

File tree

apps/report/e2e/detail-panel.yaml

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,20 @@ tasks:
1919

2020
- name: inspect detail side sections
2121
flow:
22-
- ai: Click on the first task row in the left sidebar that has type "Planning" or "Insight" or "Locate"
22+
- ai: Click on the first Planning task row in the left sidebar
2323
- sleep: 1000
24-
- aiAssert: The Param section is visible in the right detail side panel and shows task parameters or input data
25-
# Locate-type tasks render this section as "Element"; every other task
26-
# renders it as "Output". Accept either label so the test is stable
27-
# regardless of which task the previous step picked.
28-
- ai: Click on the "Output" or "Element" section header in the right detail side panel to collapse that section
24+
- aiAssert: The right detail side panel shows collapsible section headers named Param and Output, and the Param section is expanded with task input data
25+
# Collapse both Param and the Output/Element section so the Meta section
26+
# (always rendered last) rises to the top of the panel with its heading
27+
# and first metadata rows fully on screen. This avoids depending on
28+
# scroll position: Meta is long, so any partial scroll leaves either the
29+
# heading or the type/status/start rows out of the viewport.
30+
- aiTap: Output section header in the right detail side panel
31+
- sleep: 500
32+
- aiAssert: The Output section is collapsed; the Output header is still visible but the output content under it is hidden
33+
- aiTap: Param section header in the right detail side panel
2934
- sleep: 1000
30-
- aiAssert: The Meta section is visible in the right detail side panel and contains metadata entries such as type, status, start, end, or total time
35+
- aiAssert: The Meta section is visible in the right detail side panel and shows metadata entries such as type, status, start, end, or total time; Param and Output are collapsed above it
3136

3237
- name: open in playground button
3338
flow:

apps/report/e2e/report-single.yaml

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,13 @@ tasks:
3232
# --- Dark mode ---
3333
- name: toggle dark mode
3434
flow:
35-
- aiTap:
36-
locate:
37-
prompt: the "Toggle theme" button with a sun or moon icon in the top right header area
38-
deepLocate: true
35+
- aiTap: theme toggle button in the top right header
3936
- sleep: 2000
40-
- aiAssert: The report UI (header, sidebar, and surrounding chrome) has a dark background color
37+
- aiAssert: The report UI is in dark theme; the top header, left sidebar, and page chrome are dark charcoal or black rather than white
4138

4239
- name: toggle back to light mode
4340
flow:
44-
- aiTap:
45-
locate:
46-
prompt: the "Toggle theme" button with a sun or moon icon in the top right header area
47-
deepLocate: true
41+
- aiAssert: The report UI is currently in dark theme before clicking the theme toggle again
42+
- aiTap: theme toggle button in the top right header
4843
- sleep: 2000
49-
- aiAssert: The report UI (header, sidebar, and surrounding chrome) has a light/white background color
44+
- aiAssert: The report UI is in light theme; the top header, left sidebar, and page chrome are light or white rather than dark charcoal or black

apps/report/e2e/theme-toggle.yaml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,16 @@ tasks:
55
- name: toggle to dark mode
66
flow:
77
- sleep: 2000
8-
- ai: Click the "Toggle theme" button in the top right header area
8+
- aiTap: theme toggle button in the top right header
99
- sleep: 2000
10-
- aiAssert: The report UI (header, sidebar, and surrounding chrome) has a dark background color
10+
- aiAssert: The report UI is in dark theme; the top header, left sidebar, and page chrome are dark charcoal or black rather than white
1111

1212
- name: toggle back to light mode
1313
flow:
14-
- ai: Click the "Toggle theme" button in the top right header area
14+
- aiAssert: The report UI is currently in dark theme before clicking the theme toggle again
15+
- aiTap: theme toggle button in the top right header
1516
- sleep: 2000
16-
- aiAssert: The report UI (header, sidebar, and surrounding chrome) has a light/white background color
17+
- aiAssert: The report UI is in light theme; the top header, left sidebar, and page chrome are light or white rather than dark charcoal or black
1718

1819
- name: version info displayed
1920
flow:

packages/computer/src/device.ts

Lines changed: 95 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,35 @@ const EDGE_SCROLL_STEPS = 400;
9090
// minimum of 10 steps so small distances still feel momentum-like.
9191
const PHASED_PIXELS_PER_STEP = 30;
9292
const PHASED_MIN_STEPS = 10;
93+
// libnut fallback (Windows / Linux, and macOS when phased-scroll is
94+
// unavailable). libnut.scrollMouse(x, y) is *not* a portable pixel API:
95+
// - macOS: y is a CG pixel scroll delta
96+
// - Linux: y is the number of XButton4/5 press-release pairs (1 = one
97+
// wheel notch)
98+
// - Windows: y is forwarded directly to MOUSEEVENTF_WHEEL's `mouseData`,
99+
// where the unit is WHEEL_DELTA (= 120) per detent
100+
// Calling scrollMouse(0, 6) on Windows therefore sends `mouseData = 6` —
101+
// far below one detent — which gets silently accumulated and is frequently
102+
// discarded by Chromium's WheelEventQueue (Electron apps like Lark/Feishu
103+
// see this as "scroll did nothing"). Always emit one detent per call, and
104+
// pace the calls so blink doesn't coalesce them into a single tick.
105+
const LIBNUT_FALLBACK_PIXELS_PER_DETENT = 100;
106+
const LIBNUT_FALLBACK_TICK_DELAY_MS = 30;
107+
const LIBNUT_FALLBACK_MAX_DETENTS = 200;
108+
const LIBNUT_FALLBACK_DETENT_AMOUNT = process.platform === 'win32' ? 120 : 1;
109+
// Edge scrolls (scrollToTop / scrollToBottom / ...) must drive all the way to
110+
// the boundary on every backend. The phased path requests EDGE_SCROLL_TOTAL_PX
111+
// (50_000 px); the libnut fallback aims for the same distance, capped at
112+
// LIBNUT_FALLBACK_MAX_DETENTS so a misconfigured screen size can't wedge the
113+
// process. Chromium clamps wheel events at the boundary, so overshooting is
114+
// free. Exported for regression coverage.
115+
export const LIBNUT_FALLBACK_EDGE_DETENTS = Math.min(
116+
LIBNUT_FALLBACK_MAX_DETENTS,
117+
Math.max(
118+
1,
119+
Math.ceil(EDGE_SCROLL_TOTAL_PX / LIBNUT_FALLBACK_PIXELS_PER_DETENT),
120+
),
121+
);
93122
// Default scroll distance is 70% of the screen size on the relevant axis,
94123
// matching the web puppeteer/chrome-extension behavior so a model that simply
95124
// says "scroll down" without a distance gets a roughly one-screen scroll on
@@ -105,17 +134,20 @@ type EdgeScrollType =
105134
interface EdgeScrollStrategy {
106135
direction: ScrollDirection;
107136
key: 'home' | 'end';
137+
// Unit vector for libnut.scrollMouse direction. Magnitude is applied
138+
// separately by emitDetents() so each call is one full detent on every
139+
// platform (see LIBNUT_FALLBACK_DETENT_AMOUNT).
108140
libnut: readonly [number, number];
109141
}
110142

111143
// Single source of truth for edge scroll dispatch. Adding a new scrollType
112144
// only requires one entry here; the three backends (phased binary /
113145
// AppleScript / libnut) all read from the same spec.
114146
const EDGE_SCROLL_SPEC: Record<EdgeScrollType, EdgeScrollStrategy> = {
115-
scrollToTop: { direction: 'up', key: 'home', libnut: [0, 10] },
116-
scrollToBottom: { direction: 'down', key: 'end', libnut: [0, -10] },
117-
scrollToLeft: { direction: 'left', key: 'home', libnut: [-10, 0] },
118-
scrollToRight: { direction: 'right', key: 'end', libnut: [10, 0] },
147+
scrollToTop: { direction: 'up', key: 'home', libnut: [0, 1] },
148+
scrollToBottom: { direction: 'down', key: 'end', libnut: [0, -1] },
149+
scrollToLeft: { direction: 'left', key: 'home', libnut: [-1, 0] },
150+
scrollToRight: { direction: 'right', key: 'end', libnut: [1, 0] },
119151
};
120152

121153
// macOS AppleScript key code mapping
@@ -1170,13 +1202,47 @@ Original error: ${lastRawMessage}`,
11701202
this.inputDriver.sendKey(key, modifiers);
11711203
}
11721204

1173-
private async performScroll(param: any): Promise<void> {
1205+
private resolveUntargetedScrollPoint(screenSize: Size): Point {
1206+
if (process.platform === 'win32') {
1207+
const activeWindowRect = this.inputDriver.getActiveWindowRect();
1208+
if (activeWindowRect) {
1209+
return {
1210+
x: activeWindowRect.x + activeWindowRect.width / 2,
1211+
y: activeWindowRect.y + activeWindowRect.height / 2,
1212+
};
1213+
}
1214+
}
1215+
1216+
return this.toGlobalPoint({
1217+
x: screenSize.width / 2,
1218+
y: screenSize.height / 2,
1219+
});
1220+
}
1221+
1222+
private async moveMouseToScrollTarget(param: any): Promise<Size | undefined> {
11741223
if (param.locate) {
11751224
const element = param.locate as LocateResultElement;
11761225
const [x, y] = element.center;
11771226
const point = this.toGlobalPoint({ x, y });
11781227
this.inputDriver.moveMouse(Math.round(point.x), Math.round(point.y));
1228+
return undefined;
1229+
}
1230+
1231+
// Wheel events are delivered to the window under the cursor. For an
1232+
// untargeted "scroll down", anchor the cursor in the active viewport
1233+
// instead of relying on wherever the previous action left it.
1234+
const screenSize = await this.size();
1235+
if (process.platform === 'win32' && this.inputDriver.focusActiveWindow()) {
1236+
await this.inputDriver.delay(CLICK_FOCUS_SETTLE_DELAY);
11791237
}
1238+
const point = this.resolveUntargetedScrollPoint(screenSize);
1239+
this.inputDriver.moveMouse(Math.round(point.x), Math.round(point.y));
1240+
await this.inputDriver.delay(MOUSE_MOVE_EFFECT_WAIT);
1241+
return screenSize;
1242+
}
1243+
1244+
private async performScroll(param: any): Promise<void> {
1245+
let screenSize = await this.moveMouseToScrollTarget(param);
11801246

11811247
const scrollType = param?.scrollType;
11821248

@@ -1202,11 +1268,13 @@ Original error: ${lastRawMessage}`,
12021268
return;
12031269
}
12041270

1205-
const [dx, dy] = edgeSpec.libnut;
1206-
for (let i = 0; i < SCROLL_REPEAT_COUNT; i++) {
1207-
this.inputDriver.scrollMouse(dx, dy);
1208-
await this.inputDriver.delay(SCROLL_STEP_DELAY);
1209-
}
1271+
const [ux, uy] = edgeSpec.libnut;
1272+
await this.inputDriver.emitScrollDetents(
1273+
ux * LIBNUT_FALLBACK_DETENT_AMOUNT,
1274+
uy * LIBNUT_FALLBACK_DETENT_AMOUNT,
1275+
LIBNUT_FALLBACK_EDGE_DETENTS,
1276+
LIBNUT_FALLBACK_TICK_DELAY_MS,
1277+
);
12101278
return;
12111279
}
12121280

@@ -1220,9 +1288,8 @@ Original error: ${lastRawMessage}`,
12201288
const isHorizontal = direction === 'left' || direction === 'right';
12211289

12221290
let distance: number | undefined = param?.distance ?? undefined;
1223-
let screenSize: Size | undefined;
12241291
if (!distance) {
1225-
screenSize = await this.size();
1292+
screenSize ??= await this.size();
12261293
const base = isHorizontal ? screenSize.width : screenSize.height;
12271294
distance = Math.max(
12281295
1,
@@ -1253,16 +1320,23 @@ Original error: ${lastRawMessage}`,
12531320
return;
12541321
}
12551322

1256-
const ticks = Math.ceil(distance / 100);
1257-
const directionMap: Record<string, [number, number]> = {
1258-
up: [0, ticks],
1259-
down: [0, -ticks],
1260-
left: [-ticks, 0],
1261-
right: [ticks, 0],
1323+
const detents = Math.min(
1324+
LIBNUT_FALLBACK_MAX_DETENTS,
1325+
Math.max(1, Math.ceil(distance / LIBNUT_FALLBACK_PIXELS_PER_DETENT)),
1326+
);
1327+
const directionUnit: Record<string, [number, number]> = {
1328+
up: [0, 1],
1329+
down: [0, -1],
1330+
left: [-1, 0],
1331+
right: [1, 0],
12621332
};
1263-
1264-
const [dx, dy] = directionMap[direction] || [0, -ticks];
1265-
this.inputDriver.scrollMouse(dx, dy);
1333+
const [ux, uy] = directionUnit[direction] || [0, -1];
1334+
await this.inputDriver.emitScrollDetents(
1335+
ux * LIBNUT_FALLBACK_DETENT_AMOUNT,
1336+
uy * LIBNUT_FALLBACK_DETENT_AMOUNT,
1337+
detents,
1338+
LIBNUT_FALLBACK_TICK_DELAY_MS,
1339+
);
12661340
await this.inputDriver.delay(SCROLL_COMPLETE_DELAY);
12671341
return;
12681342
}

packages/computer/src/input-driver.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ export interface LibNut {
1010
scrollMouse(x: number, y: number): void;
1111
keyTap(key: string, modifiers?: string[]): void;
1212
typeString(text: string): void;
13+
getActiveWindow?(): number;
14+
getWindowRect?(handle: number): WindowRect;
15+
focusWindow?(handle: number): void;
1316
}
1417

1518
export type MouseButton = 'left' | 'right' | 'middle';
1619
export type ScrollDirection = 'up' | 'down' | 'left' | 'right';
1720

21+
export interface WindowRect {
22+
x: number;
23+
y: number;
24+
width: number;
25+
height: number;
26+
}
27+
1828
interface ComputerInputDriverOptions {
1929
getLibnut(): LibNut | null;
2030
useAppleScript(): boolean;
@@ -56,6 +66,58 @@ export class ComputerInputDriver {
5666
this.getLibnutOrThrow('moveMouse').moveMouse(x, y);
5767
}
5868

69+
focusActiveWindow(): boolean {
70+
const lib = this.getLibnutOrThrow('focusActiveWindow');
71+
if (
72+
typeof lib.getActiveWindow !== 'function' ||
73+
typeof lib.focusWindow !== 'function'
74+
) {
75+
return false;
76+
}
77+
78+
try {
79+
const handle = lib.getActiveWindow();
80+
if (!handle) return false;
81+
lib.focusWindow(handle);
82+
return true;
83+
} catch (error) {
84+
this.options.debug(`focusActiveWindow failed: ${error}`);
85+
return false;
86+
}
87+
}
88+
89+
getActiveWindowRect(): WindowRect | null {
90+
const lib = this.getLibnutOrThrow('getActiveWindowRect');
91+
if (
92+
typeof lib.getActiveWindow !== 'function' ||
93+
typeof lib.getWindowRect !== 'function'
94+
) {
95+
return null;
96+
}
97+
98+
try {
99+
const handle = lib.getActiveWindow();
100+
if (!handle) return null;
101+
102+
const rect = lib.getWindowRect(handle);
103+
if (
104+
!Number.isFinite(rect.x) ||
105+
!Number.isFinite(rect.y) ||
106+
!Number.isFinite(rect.width) ||
107+
!Number.isFinite(rect.height) ||
108+
rect.width <= 0 ||
109+
rect.height <= 0
110+
) {
111+
return null;
112+
}
113+
114+
return rect;
115+
} catch (error) {
116+
this.options.debug(`getActiveWindowRect failed: ${error}`);
117+
return null;
118+
}
119+
}
120+
59121
mouseClick(button?: MouseButton, double?: boolean): void {
60122
const lib = this.getLibnutOrThrow('mouseClick');
61123
// libnut is a native binding that distinguishes "no argument" from
@@ -78,6 +140,29 @@ export class ComputerInputDriver {
78140
this.getLibnutOrThrow('scrollMouse').scrollMouse(x, y);
79141
}
80142

143+
/**
144+
* Emit one `libnut.scrollMouse` call per detent and pace them with
145+
* `delayMs`. Per-call magnitude is fixed by the caller so each call is
146+
* exactly one detent on the target platform — on Windows the libnut
147+
* binding forwards `mouseData` straight to `MOUSEEVENTF_WHEEL`, where
148+
* sub-WHEEL_DELTA values (< 120) get accumulated and frequently dropped
149+
* by Chromium's WheelEventQueue.
150+
*/
151+
async emitScrollDetents(
152+
dx: number,
153+
dy: number,
154+
detents: number,
155+
delayMs: number,
156+
): Promise<void> {
157+
this.assertActive('emitScrollDetents');
158+
for (let i = 0; i < detents; i++) {
159+
this.scrollMouse(dx, dy);
160+
if (i < detents - 1) {
161+
await this.delay(delayMs);
162+
}
163+
}
164+
}
165+
81166
keyTap(key: string, modifiers?: string[]): void {
82167
const lib = this.getLibnutOrThrow('keyTap');
83168
// See note on mouseClick — avoid passing explicit undefined to libnut.

0 commit comments

Comments
 (0)