getobsrv 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/out/cli/args.js +5 -1
- package/out/main/cli.js +51 -4
- package/out/main/index.js +249 -53
- package/out/main/{targetSource-DkXWE0ha.js → targetSource-BKW32VA5.js} +15 -5
- package/out/mcp/lib.js +8 -4
- package/out/mcp/server.js +173 -12
- package/out/preload/sync.js +102 -5
- package/out/renderer/assets/{index-FtoKShe_.css → index-CHF-G97L.css} +21 -2
- package/out/renderer/assets/{index-P496vEhv.js → index-DyCD4ih_.js} +123 -38
- package/out/renderer/index.html +2 -2
- package/out/shared/api.js +2 -0
- package/out/shared/control.js +74 -1
- package/out/shared/ipcPayloads.js +208 -0
- package/out/shared/types.js +3 -0
- package/package.json +3 -2
- package/skills/obsrv-screens/SKILL.md +3 -2
package/out/mcp/server.js
CHANGED
|
@@ -11,6 +11,7 @@ const zod_1 = require("zod");
|
|
|
11
11
|
const args_1 = require("../cli/args");
|
|
12
12
|
const control_1 = require("../shared/control");
|
|
13
13
|
const presets_1 = require("../shared/presets");
|
|
14
|
+
const types_1 = require("../shared/types");
|
|
14
15
|
const control_2 = require("./control");
|
|
15
16
|
const lib_1 = require("./lib");
|
|
16
17
|
/**
|
|
@@ -123,6 +124,11 @@ const snapInputShape = {
|
|
|
123
124
|
.optional()
|
|
124
125
|
.describe('auto (default): drive the visible Obsrv app when it is open with Agent control on, else render headlessly. ' +
|
|
125
126
|
'live: require the app (error if unreachable). headless: never touch the app.'),
|
|
127
|
+
capture: zod_1.z
|
|
128
|
+
.enum(['window', 'pane'])
|
|
129
|
+
.optional()
|
|
130
|
+
.describe("Live mode only: what the returned PNG shows — 'window' (default) is the whole app window, 'pane' is just " +
|
|
131
|
+
'the target pane (its footer readout included). Ignored (with a note) when the render is headless.'),
|
|
126
132
|
};
|
|
127
133
|
const snapOutputShape = {
|
|
128
134
|
mode: zod_1.z
|
|
@@ -136,15 +142,25 @@ const snapOutputShape = {
|
|
|
136
142
|
profile: zod_1.z.string().optional().describe('Headless only: applied panel profile id.'),
|
|
137
143
|
settled: zod_1.z
|
|
138
144
|
.boolean()
|
|
139
|
-
.describe('Headless: the page went paint-quiet.
|
|
145
|
+
.describe('Headless: the page went paint-quiet and every pixel painted. False is still a usable capture — a page that ' +
|
|
146
|
+
'kept animating, or one whose repaint never completed, is returned as-is with a warning saying what was ' +
|
|
147
|
+
'missing. Live: the app confirmed the navigation before the capture.'),
|
|
140
148
|
warnings: zod_1.z.array(zod_1.z.string()),
|
|
141
149
|
pngPath: zod_1.z.string().describe('Absolute path of the captured PNG (kept in a per-call temp dir).'),
|
|
142
150
|
url: zod_1.z.string().optional().describe('Live only: the URL the app reports showing.'),
|
|
143
151
|
presetId: zod_1.z.string().optional().describe('Live only: the screen preset selected in the app.'),
|
|
144
152
|
profileId: zod_1.z.string().optional().describe('Live only: the panel profile selected in the app.'),
|
|
145
153
|
viewMode: zod_1.z.string().optional().describe("Live only: the app's target-pane view (1:1 or fit)."),
|
|
146
|
-
width: zod_1.z
|
|
147
|
-
|
|
154
|
+
width: zod_1.z
|
|
155
|
+
.number()
|
|
156
|
+
.optional()
|
|
157
|
+
.describe('Live only: captured width in device-independent px (the app window, or the target pane under capture: ' +
|
|
158
|
+
'"pane"); the PNG raster is this times the display scale.'),
|
|
159
|
+
height: zod_1.z
|
|
160
|
+
.number()
|
|
161
|
+
.optional()
|
|
162
|
+
.describe('Live only: captured height in device-independent px (the app window, or the target pane under capture: ' +
|
|
163
|
+
'"pane"); the PNG raster is this times the display scale.'),
|
|
148
164
|
};
|
|
149
165
|
const diffInputShape = {
|
|
150
166
|
url: urlField,
|
|
@@ -214,6 +230,49 @@ const driveInputShape = {
|
|
|
214
230
|
preset: zod_1.z.enum(PRESET_IDS).optional().describe('Apply this screen preset, exactly as clicking the toolbar would.'),
|
|
215
231
|
profile: zod_1.z.enum(PROFILE_IDS).optional().describe('Apply this panel profile in the app.'),
|
|
216
232
|
viewMode: zod_1.z.enum(['1:1', 'fit']).optional().describe("Switch the app's target pane between 1:1 (actual size) and fit."),
|
|
233
|
+
pixelExact: zod_1.z.boolean().optional().describe("Toggle the toolbar's pixel-exact checkbox (pins the magnification to the host scale)."),
|
|
234
|
+
focus: zod_1.z.boolean().optional().describe('true: bring the Obsrv window to the front first, so the user sees what follows.'),
|
|
235
|
+
reload: zod_1.z.boolean().optional().describe('true: reload both panes (the same action as the toolbar reload).'),
|
|
236
|
+
back: zod_1.z.boolean().optional().describe('true: history back (native pane history; the target mirrors the committed page).'),
|
|
237
|
+
forward: zod_1.z.boolean().optional().describe('true: history forward (native pane history; the target mirrors it).'),
|
|
238
|
+
scroll: zod_1.z
|
|
239
|
+
.object({
|
|
240
|
+
x: zod_1.z.number().min(0),
|
|
241
|
+
y: zod_1.z.number().min(0),
|
|
242
|
+
scrollSelector: zod_1.z
|
|
243
|
+
.string()
|
|
244
|
+
.min(1)
|
|
245
|
+
.max(types_1.MAX_SCROLL_SELECTOR)
|
|
246
|
+
.optional()
|
|
247
|
+
.describe('Escape hatch: a CSS selector naming the element to scroll, for pages whose scroll host the automatic ' +
|
|
248
|
+
'detection misjudges (several large scrollers, a virtualised list that translates content). No fallback ' +
|
|
249
|
+
'if it matches nothing — the result says so. Same reach as the detection: light DOM of the top-level ' +
|
|
250
|
+
'document only, so a scroller inside a shadow root or an iframe cannot be targeted.'),
|
|
251
|
+
})
|
|
252
|
+
.optional()
|
|
253
|
+
.describe('Scroll both panes to this absolute page offset in CSS px. Pages whose root cannot scroll (app shells with ' +
|
|
254
|
+
'`html, body { overflow: hidden }` and an inner `overflow-y: auto` container) are handled: the largest ' +
|
|
255
|
+
'visible inner scroller is found and scrolled instead. Check `scrolled` in the result for the offset ' +
|
|
256
|
+
'actually reached — that is how you tell a real scroll from one that clamped.'),
|
|
257
|
+
panTo: zod_1.z
|
|
258
|
+
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
259
|
+
.optional()
|
|
260
|
+
.describe("Centre this target-pane pixel (device px of the render) in the pane's 1:1 view; from fit this jumps to 1:1 there."),
|
|
261
|
+
click: zod_1.z
|
|
262
|
+
.object({ x: zod_1.z.number().min(0), y: zod_1.z.number().min(0) })
|
|
263
|
+
.optional()
|
|
264
|
+
.describe('Left-click the live page at these CSS-viewport coordinates (may navigate; refused outside the viewport).'),
|
|
265
|
+
highlight: zod_1.z
|
|
266
|
+
.object({
|
|
267
|
+
x: zod_1.z.number().min(0),
|
|
268
|
+
y: zod_1.z.number().min(0),
|
|
269
|
+
width: zod_1.z.number().min(1),
|
|
270
|
+
height: zod_1.z.number().min(1),
|
|
271
|
+
durationMs: zod_1.z.number().optional(),
|
|
272
|
+
})
|
|
273
|
+
.optional()
|
|
274
|
+
.describe('Draw a temporary neutral marker over this target-pixel rect in the pane (durationMs default 2000, clamped ' +
|
|
275
|
+
'250-10000). A new highlight replaces the previous one.'),
|
|
217
276
|
};
|
|
218
277
|
const driveOutputShape = {
|
|
219
278
|
version: zod_1.z.string().describe('The running app version.'),
|
|
@@ -222,6 +281,18 @@ const driveOutputShape = {
|
|
|
222
281
|
profileId: zod_1.z.string(),
|
|
223
282
|
viewMode: zod_1.z.string(),
|
|
224
283
|
mode: zod_1.z.string().describe("The app's pane mode: 'url' (live page) or 'image' (a dropped design export)."),
|
|
284
|
+
scrolled: zod_1.z
|
|
285
|
+
.object({ x: zod_1.z.number(), y: zod_1.z.number() })
|
|
286
|
+
.nullable()
|
|
287
|
+
.optional()
|
|
288
|
+
.describe('Only when `scroll` was requested: the offset the target pane actually reached, read back after the write. ' +
|
|
289
|
+
'Less than you asked for means the content clamped (short page, or the wrong scroller). Null means the ' +
|
|
290
|
+
'pane did not confirm in time — the scroll may still have landed.'),
|
|
291
|
+
scroller: zod_1.z
|
|
292
|
+
.enum(['root', 'element'])
|
|
293
|
+
.optional()
|
|
294
|
+
.describe("Only when `scroll` was requested: 'root' if the document scrolled, 'element' if an inner scroll container did."),
|
|
295
|
+
warnings: zod_1.z.array(zod_1.z.string()).optional().describe('Anything worth knowing about the commands that ran (e.g. a scrollSelector that matched nothing).'),
|
|
225
296
|
};
|
|
226
297
|
// --- live drive --------------------------------------------------------------
|
|
227
298
|
/** Budget for one control `status` round-trip once the app is known live. */
|
|
@@ -232,6 +303,13 @@ const LIVE_APPLY_TIMEOUT_MS = 5_000;
|
|
|
232
303
|
const LIVE_CAPTURE_TIMEOUT_MS = 30_000;
|
|
233
304
|
/** How long a live snap waits for `status.url` to reflect the navigation. */
|
|
234
305
|
const LIVE_SETTLE_MS = 5_000;
|
|
306
|
+
/**
|
|
307
|
+
* How long an `obsrv_drive` click waits for a navigation it may have caused,
|
|
308
|
+
* so the returned status reflects it. Deliberately short: most clicks do not
|
|
309
|
+
* navigate, and every non-navigating one pays this in full.
|
|
310
|
+
*/
|
|
311
|
+
const CLICK_SETTLE_MS = 2_000;
|
|
312
|
+
const CLICK_SETTLE_POLL_MS = 250;
|
|
235
313
|
function liveFailure(e) {
|
|
236
314
|
const msg = e instanceof Error ? e.message : String(e);
|
|
237
315
|
return (`${msg}. If the Obsrv app was closed or Agent control was toggled off mid-call, ` +
|
|
@@ -287,9 +365,13 @@ async function liveSnap(app, input, notes) {
|
|
|
287
365
|
// (and any preset resize) a frame or two after the store confirms, and a
|
|
288
366
|
// capture racing that would show a half-applied flip.
|
|
289
367
|
await sleep(300);
|
|
368
|
+
// `capture: 'pane'` crops to the target pane; the command answers with the
|
|
369
|
+
// same { data, width, height } shape plus its own warnings (e.g. the
|
|
370
|
+
// pre-mount full-window fallback), which join the tool's.
|
|
290
371
|
let capture;
|
|
291
372
|
try {
|
|
292
|
-
|
|
373
|
+
const command = input.capture === 'pane' ? 'captureTarget' : 'captureVisible';
|
|
374
|
+
capture = await (0, control_2.controlCall)(info, command, {}, LIVE_CAPTURE_TIMEOUT_MS);
|
|
293
375
|
}
|
|
294
376
|
catch (e) {
|
|
295
377
|
return toolError(liveFailure(e));
|
|
@@ -298,6 +380,11 @@ async function liveSnap(app, input, notes) {
|
|
|
298
380
|
if (typeof data !== 'string' || typeof width !== 'number' || typeof height !== 'number') {
|
|
299
381
|
return toolError('the control server returned a malformed capture');
|
|
300
382
|
}
|
|
383
|
+
if (Array.isArray(capture['warnings'])) {
|
|
384
|
+
for (const w of capture['warnings'])
|
|
385
|
+
if (typeof w === 'string')
|
|
386
|
+
warnings.push(w);
|
|
387
|
+
}
|
|
301
388
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
302
389
|
const pngPath = (0, node_path_1.join)(dir, 'live.png');
|
|
303
390
|
await (0, promises_1.writeFile)(pngPath, Buffer.from(data, 'base64'));
|
|
@@ -336,7 +423,8 @@ server.registerTool('obsrv_snap', {
|
|
|
336
423
|
`Live drive: when the Obsrv desktop app is open with its "Agent control" toolbar toggle on, \`mode: "auto"\` ` +
|
|
337
424
|
`(the default) drives the *visible* app instead — the user watches the URL load and the preset flip, and the ` +
|
|
338
425
|
`returned PNG is the app window as they see it (\`mode: "live"\` in the result; \`mode: "headless"\` ` +
|
|
339
|
-
`otherwise).
|
|
426
|
+
`otherwise). \`capture: "pane"\` crops a live capture to just the target pane (headless renders ignore it ` +
|
|
427
|
+
`with a note). Custom width/height and \`fullPage\` always render headlessly (with a note); \`waitMs\` is ` +
|
|
340
428
|
`ignored in live mode. \`mode: "live"\` errors when the app is not reachable; \`mode: "headless"\` never ` +
|
|
341
429
|
`touches it. Note: although this tool is annotated read-only (it renders and captures), a live snap steers ` +
|
|
342
430
|
`the open app window — navigating it and flipping its preset in front of the user — as its means of ` +
|
|
@@ -362,6 +450,9 @@ server.registerTool('obsrv_snap', {
|
|
|
362
450
|
return liveSnap(live, input, plan.notes);
|
|
363
451
|
liveNotes = plan.notes;
|
|
364
452
|
}
|
|
453
|
+
else if (input.capture === 'pane') {
|
|
454
|
+
liveNotes = [lib_1.PANE_CAPTURE_HEADLESS_NOTE];
|
|
455
|
+
}
|
|
365
456
|
const dir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'obsrv-mcp-'));
|
|
366
457
|
const pngPath = (0, node_path_1.join)(dir, 'snap.png');
|
|
367
458
|
let args;
|
|
@@ -436,12 +527,23 @@ server.registerTool('obsrv_diff', {
|
|
|
436
527
|
server.registerTool('obsrv_drive', {
|
|
437
528
|
title: 'Drive the visible Obsrv app',
|
|
438
529
|
description: `Drive the Obsrv desktop app the user is looking at: navigate it to a URL, apply a screen preset, a panel ` +
|
|
439
|
-
`profile,
|
|
440
|
-
|
|
441
|
-
`
|
|
530
|
+
`profile, the target pane's 1:1/fit view or pixel-exact toggle — each exactly as clicking the toolbar would ` +
|
|
531
|
+
`— and steer the session like a guided demo: focus the window, step history (back/forward/reload), scroll ` +
|
|
532
|
+
`both panes, pan the target pane to a pixel, click the live page, and highlight a rect with a temporary ` +
|
|
533
|
+
`neutral marker, all while the user watches.\n\n` +
|
|
534
|
+
`Only the supplied inputs run (none = just read the current state), in this fixed order: focus → url → ` +
|
|
535
|
+
`preset → profile → viewMode → pixelExact → reload → back → forward → scroll → panTo → click → highlight. ` +
|
|
536
|
+
`The result is the final status: app version, the URL showing, and the selected preset/profile/view. A ` +
|
|
537
|
+
`click that navigates is reflected in that status — the call waits briefly (up to 2 s) for the commit. A ` +
|
|
538
|
+
`scroll adds \`scrolled\` (the offset actually reached) and \`scroller\` ('root' or 'element'): compare ` +
|
|
539
|
+
`\`scrolled\` with what you asked for rather than trusting the call's success, and use \`scroll.scrollSelector\` ` +
|
|
540
|
+
`when the automatic scroll-host detection picks the wrong container.\n\n` +
|
|
541
|
+
`Coordinates: click takes CSS-viewport px of the page (the valid range is 0 up to but not including the ` +
|
|
542
|
+
`viewport size); panTo and highlight take target-pane pixels (device px of the render — identical to CSS px ` +
|
|
543
|
+
`on 1x presets); scroll takes page CSS px.\n\n` +
|
|
442
544
|
`Requires the app to be open with its "Agent control" toolbar toggle on; errors otherwise. This tool ` +
|
|
443
|
-
`mutates visible app state (it changes what the user's window shows
|
|
444
|
-
`obsrv_snap for a capture.`,
|
|
545
|
+
`mutates visible app state (it changes what the user's window shows, and a click can act on the live page) ` +
|
|
546
|
+
`but renders nothing itself — use obsrv_snap for a capture.`,
|
|
445
547
|
inputSchema: driveInputShape,
|
|
446
548
|
outputSchema: driveOutputShape,
|
|
447
549
|
// Honest annotation: this changes what the user's window is showing.
|
|
@@ -456,6 +558,10 @@ server.registerTool('obsrv_drive', {
|
|
|
456
558
|
if (!live)
|
|
457
559
|
return toolError(lib_1.APP_NOT_REACHABLE);
|
|
458
560
|
try {
|
|
561
|
+
// The documented execution order: window attention first, then what is
|
|
562
|
+
// showing, then how it is shown, then the in-page steering.
|
|
563
|
+
if (input.focus)
|
|
564
|
+
await (0, control_2.controlCall)(live.info, 'focusWindow', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
459
565
|
if (input.url !== undefined) {
|
|
460
566
|
await (0, control_2.controlCall)(live.info, 'navigate', { url: input.url.trim() }, args_1.DEFAULT_TIMEOUT_MS + 10_000);
|
|
461
567
|
}
|
|
@@ -466,12 +572,67 @@ server.registerTool('obsrv_drive', {
|
|
|
466
572
|
if (input.viewMode !== undefined) {
|
|
467
573
|
await (0, control_2.controlCall)(live.info, 'setViewMode', { mode: input.viewMode }, LIVE_APPLY_TIMEOUT_MS);
|
|
468
574
|
}
|
|
575
|
+
if (input.pixelExact !== undefined) {
|
|
576
|
+
await (0, control_2.controlCall)(live.info, 'setPixelExact', { on: input.pixelExact }, LIVE_APPLY_TIMEOUT_MS);
|
|
577
|
+
}
|
|
578
|
+
if (input.reload)
|
|
579
|
+
await (0, control_2.controlCall)(live.info, 'reload', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
580
|
+
if (input.back)
|
|
581
|
+
await (0, control_2.controlCall)(live.info, 'back', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
582
|
+
if (input.forward)
|
|
583
|
+
await (0, control_2.controlCall)(live.info, 'forward', {}, LIVE_APPLY_TIMEOUT_MS);
|
|
584
|
+
// The scroll answer is the interesting half: it reports the offset the
|
|
585
|
+
// pane reached, which is the only way to tell a scroll from a clamp.
|
|
586
|
+
let scrolled;
|
|
587
|
+
let scroller;
|
|
588
|
+
const warnings = [];
|
|
589
|
+
if (input.scroll !== undefined) {
|
|
590
|
+
const r = await (0, control_2.controlCall)(live.info, 'scroll', input.scroll, LIVE_APPLY_TIMEOUT_MS);
|
|
591
|
+
const at = r['scrolled'];
|
|
592
|
+
scrolled =
|
|
593
|
+
at !== null && typeof at === 'object' && typeof at.x === 'number' && typeof at.y === 'number'
|
|
594
|
+
? { x: at.x, y: at.y }
|
|
595
|
+
: null;
|
|
596
|
+
if (r['scroller'] === 'root' || r['scroller'] === 'element')
|
|
597
|
+
scroller = r['scroller'];
|
|
598
|
+
if (Array.isArray(r['warnings']))
|
|
599
|
+
for (const w of r['warnings'])
|
|
600
|
+
if (typeof w === 'string')
|
|
601
|
+
warnings.push(w);
|
|
602
|
+
}
|
|
603
|
+
if (input.panTo !== undefined)
|
|
604
|
+
await (0, control_2.controlCall)(live.info, 'panTo', input.panTo, LIVE_APPLY_TIMEOUT_MS);
|
|
605
|
+
if (input.click !== undefined) {
|
|
606
|
+
// A click may navigate. Note the URL first, then wait — bounded and
|
|
607
|
+
// short, the same settle idea as a live snap — for the status to move
|
|
608
|
+
// off it, so the returned status reflects what the click did. A click
|
|
609
|
+
// that navigates nowhere simply rides out the short deadline.
|
|
610
|
+
const before = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS))?.url ?? '';
|
|
611
|
+
await (0, control_2.controlCall)(live.info, 'click', input.click, LIVE_APPLY_TIMEOUT_MS);
|
|
612
|
+
const deadline = Date.now() + CLICK_SETTLE_MS;
|
|
613
|
+
for (;;) {
|
|
614
|
+
const s = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
615
|
+
if (s && s.url !== before && s.url !== 'about:blank')
|
|
616
|
+
break;
|
|
617
|
+
if (Date.now() >= deadline)
|
|
618
|
+
break;
|
|
619
|
+
await sleep(CLICK_SETTLE_POLL_MS);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
if (input.highlight !== undefined)
|
|
623
|
+
await (0, control_2.controlCall)(live.info, 'highlight', input.highlight, LIVE_APPLY_TIMEOUT_MS);
|
|
469
624
|
const status = (0, control_1.parseControlStatus)(await (0, control_2.controlCall)(live.info, 'status', {}, LIVE_STATUS_TIMEOUT_MS));
|
|
470
625
|
if (!status)
|
|
471
626
|
return toolError('the control server returned a malformed status');
|
|
627
|
+
const structured = {
|
|
628
|
+
...status,
|
|
629
|
+
...(input.scroll !== undefined ? { scrolled: scrolled ?? null } : {}),
|
|
630
|
+
...(scroller !== undefined ? { scroller } : {}),
|
|
631
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
632
|
+
};
|
|
472
633
|
return {
|
|
473
|
-
content: [{ type: 'text', text: JSON.stringify(
|
|
474
|
-
structuredContent:
|
|
634
|
+
content: [{ type: 'text', text: JSON.stringify(structured, null, 2) }],
|
|
635
|
+
structuredContent: structured,
|
|
475
636
|
};
|
|
476
637
|
}
|
|
477
638
|
catch (e) {
|
package/out/preload/sync.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
3
|
const electron = require("electron");
|
|
3
4
|
const SUPPRESS_MS = 120;
|
|
4
5
|
const SYNC_SCROLL = "obsrv:sync-scroll";
|
|
5
6
|
const APPLY_SCROLL = "obsrv:apply-scroll";
|
|
7
|
+
const SCROLL_RESULT = "obsrv:scroll-result";
|
|
8
|
+
const MAX_VISITED = 2e3;
|
|
9
|
+
const SCROLL_EPSILON = 1;
|
|
6
10
|
let suppressUntil = 0;
|
|
7
11
|
let lastApplied = null;
|
|
8
12
|
let rafId = 0;
|
|
@@ -33,9 +37,102 @@ window.addEventListener(
|
|
|
33
37
|
},
|
|
34
38
|
{ passive: true }
|
|
35
39
|
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
40
|
+
function rootScrolls() {
|
|
41
|
+
const el = document.scrollingElement;
|
|
42
|
+
if (!el) return false;
|
|
43
|
+
return el.scrollHeight > el.clientHeight + SCROLL_EPSILON || el.scrollWidth > el.clientWidth + SCROLL_EPSILON;
|
|
44
|
+
}
|
|
45
|
+
function canScroll(el) {
|
|
46
|
+
const overflowsY = el.scrollHeight > el.clientHeight + SCROLL_EPSILON;
|
|
47
|
+
const overflowsX = el.scrollWidth > el.clientWidth + SCROLL_EPSILON;
|
|
48
|
+
if (!overflowsY && !overflowsX) return false;
|
|
49
|
+
const style = window.getComputedStyle(el);
|
|
50
|
+
const scrollableY = style.overflowY === "auto" || style.overflowY === "scroll";
|
|
51
|
+
const scrollableX = style.overflowX === "auto" || style.overflowX === "scroll";
|
|
52
|
+
return overflowsY && scrollableY || overflowsX && scrollableX;
|
|
53
|
+
}
|
|
54
|
+
function isVisible(el) {
|
|
55
|
+
const check = el.checkVisibility;
|
|
56
|
+
if (typeof check !== "function") return el.getClientRects().length > 0;
|
|
57
|
+
return check.call(el, { visibilityProperty: true, opacityProperty: true });
|
|
58
|
+
}
|
|
59
|
+
function findScroller(root = document.body) {
|
|
60
|
+
if (!root) return null;
|
|
61
|
+
let best = null;
|
|
62
|
+
let bestArea = 0;
|
|
63
|
+
let visited = 0;
|
|
64
|
+
const stack = [root];
|
|
65
|
+
while (stack.length > 0) {
|
|
66
|
+
const el = stack.pop();
|
|
67
|
+
if (visited++ >= MAX_VISITED) break;
|
|
68
|
+
const area = el.clientWidth * el.clientHeight;
|
|
69
|
+
if (area <= 0 && el.getClientRects().length === 0 && window.getComputedStyle(el).display === "none") continue;
|
|
70
|
+
if (area > bestArea && canScroll(el) && isVisible(el)) {
|
|
71
|
+
best = el;
|
|
72
|
+
bestArea = area;
|
|
73
|
+
}
|
|
74
|
+
const kids = el.children;
|
|
75
|
+
for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
|
|
76
|
+
}
|
|
77
|
+
return best;
|
|
78
|
+
}
|
|
79
|
+
let cachedScroller = null;
|
|
80
|
+
function resolveScroller() {
|
|
81
|
+
if (rootScrolls()) {
|
|
82
|
+
cachedScroller = null;
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (cachedScroller && cachedScroller.isConnected && canScroll(cachedScroller)) return cachedScroller;
|
|
86
|
+
cachedScroller = findScroller();
|
|
87
|
+
return cachedScroller;
|
|
88
|
+
}
|
|
89
|
+
function applyTo(el, pos) {
|
|
90
|
+
if (el) {
|
|
91
|
+
el.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
|
|
92
|
+
return { x: el.scrollLeft, y: el.scrollTop };
|
|
93
|
+
}
|
|
94
|
+
window.scrollTo({ left: pos.x, top: pos.y, behavior: "instant" });
|
|
95
|
+
return { x: window.scrollX, y: window.scrollY };
|
|
96
|
+
}
|
|
97
|
+
electron.ipcRenderer.on(APPLY_SCROLL, (_e, req) => {
|
|
98
|
+
const pos = { x: req.x, y: req.y };
|
|
99
|
+
const warnings = [];
|
|
100
|
+
let scroller = "root";
|
|
101
|
+
let reached;
|
|
102
|
+
if (typeof req.selector === "string") {
|
|
103
|
+
let el = null;
|
|
104
|
+
try {
|
|
105
|
+
el = document.querySelector(req.selector);
|
|
106
|
+
} catch {
|
|
107
|
+
warnings.push(`scrollSelector ${JSON.stringify(req.selector)} is not a valid CSS selector; nothing was scrolled`);
|
|
108
|
+
}
|
|
109
|
+
if (el) {
|
|
110
|
+
scroller = "element";
|
|
111
|
+
reached = applyTo(el, pos);
|
|
112
|
+
if (reached.x !== pos.x || reached.y !== pos.y) {
|
|
113
|
+
warnings.push(
|
|
114
|
+
`scrollSelector ${JSON.stringify(req.selector)} matched an element that could not reach (${pos.x}, ${pos.y}); it stopped at (${reached.x}, ${reached.y})`
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
if (warnings.length === 0) {
|
|
119
|
+
warnings.push(`scrollSelector ${JSON.stringify(req.selector)} matched no element; nothing was scrolled`);
|
|
120
|
+
}
|
|
121
|
+
reached = { x: window.scrollX, y: window.scrollY };
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const el = resolveScroller();
|
|
125
|
+
scroller = el ? "element" : "root";
|
|
126
|
+
if (!el) {
|
|
127
|
+
lastApplied = pos;
|
|
128
|
+
if (window.scrollX !== pos.x || window.scrollY !== pos.y) suppressUntil = performance.now() + SUPPRESS_MS;
|
|
129
|
+
}
|
|
130
|
+
reached = applyTo(el, pos);
|
|
131
|
+
}
|
|
132
|
+
if (typeof req.id === "number") {
|
|
133
|
+
electron.ipcRenderer.send(SCROLL_RESULT, { id: req.id, x: reached.x, y: reached.y, scroller, warnings });
|
|
134
|
+
}
|
|
41
135
|
});
|
|
136
|
+
exports.MAX_VISITED = MAX_VISITED;
|
|
137
|
+
exports.findScroller = findScroller;
|
|
138
|
+
exports.resolveScroller = resolveScroller;
|
|
@@ -377,6 +377,9 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
377
377
|
position: fixed;
|
|
378
378
|
right: 16px;
|
|
379
379
|
bottom: 16px;
|
|
380
|
+
/* Above the stall notice (2) and the agent highlight (1): these are the
|
|
381
|
+
app talking to the user and must not sit under an overlay in the pane. */
|
|
382
|
+
z-index: 3;
|
|
380
383
|
padding: 8px 12px;
|
|
381
384
|
background: var(--chrome-2);
|
|
382
385
|
color: var(--text-0);
|
|
@@ -393,12 +396,28 @@ html, body, #root { margin: 0; height: 100%; background: var(--chrome-0); color:
|
|
|
393
396
|
`left: 0` holds while a wide canvas is scrolled horizontally. The fill is a
|
|
394
397
|
neutral grey: only the rule under it is red, so the one chromatic thing on
|
|
395
398
|
screen is the error itself. */
|
|
396
|
-
|
|
399
|
+
/* position: relative anchors the agent highlight to the canvas origin. */
|
|
400
|
+
.target-wrap { min-width: min-content; position: relative; }
|
|
401
|
+
|
|
402
|
+
/* Agent-control highlight: a temporary neutral marker over the target-pixel
|
|
403
|
+
rect an agent is pointing at. Style-spec law: no hue — a light outline with
|
|
404
|
+
a dark dashed inner edge reads on any page without tinting it — and no
|
|
405
|
+
pointer interception over the pixels under inspection. */
|
|
406
|
+
.agent-highlight {
|
|
407
|
+
position: absolute;
|
|
408
|
+
z-index: 1;
|
|
409
|
+
pointer-events: none;
|
|
410
|
+
outline: 2px solid var(--text-0);
|
|
411
|
+
border: 1px dashed var(--chrome-0);
|
|
412
|
+
box-sizing: border-box;
|
|
413
|
+
}
|
|
397
414
|
.stall {
|
|
398
415
|
position: sticky;
|
|
399
416
|
top: 0;
|
|
400
417
|
left: 0;
|
|
401
|
-
z-index:
|
|
418
|
+
/* Above the agent highlight (z-index 1): an error notice outranks a
|
|
419
|
+
demo marker. */
|
|
420
|
+
z-index: 2;
|
|
402
421
|
display: flex;
|
|
403
422
|
align-items: center;
|
|
404
423
|
gap: 10px;
|