Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
"dev:hmr": "node scripts/dev.mjs",
"storybook": "storybook dev -p 6006 -c .storybook",
"build-storybook": "storybook build -c .storybook --output-dir storybook-static",
"build": "npm run build:main && npm run build:preload && npm run build:renderer",
"build:test": "npm run build:main && npm run build:preload",
"build": "npm run build:main && npm run build:preload && npm run build:overlay && npm run build:renderer",
"build:test": "npm run build:main && npm run build:preload && npm run build:overlay",
"clean:main": "node ../../scripts/clean-paths.mjs dist/main tsconfig.main.tsbuildinfo",
"build:main": "tsc -p tsconfig.main.json",
"build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron",
"build:overlay": "node ../../scripts/build-cursor-overlay.mjs",
"build:renderer": "vite build && node ../../scripts/check-third-party-notices.mjs",
"typecheck": "tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit && tsc -p tsconfig.storybook.json --noEmit",
"typecheck:stories": "tsc -p tsconfig.storybook.json --noEmit",
Expand Down
5 changes: 5 additions & 0 deletions apps/desktop/scripts/dev.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { build as esbuildBuild } from 'esbuild';
import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs';

const DESKTOP_DIR = resolve(fileURLToPath(new URL('..', import.meta.url)));
const REPO_ROOT = resolve(DESKTOP_DIR, '..', '..');
Expand Down Expand Up @@ -88,6 +89,10 @@ await Promise.all([
() => log('build', 'preload — done'),
(e) => { log('build', `preload — FAILED: ${e.message}`); throw e; },
),
buildCursorOverlay({ logLevel: 'warning' }).then(
() => log('build', 'cursor overlay — done'),
(e) => { log('build', `cursor overlay — FAILED: ${e.message}`); throw e; },
),
]);

// Phase 2: main — esbuild bundle for dev startup. The full
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ describe('Desktop Computer Use production wiring', () => {
'utf8',
);
assert.match(main, /createComputerUseHost/);
assert.match(main, /createCursorOverlayController/);
assert.match(main, /createComputerUseOverlayHook/);
assert.match(main, /computerUseTools/);
assert.match(main, /id:\s*'computer_use'/);
assert.doesNotMatch(main, /createComputerUseOverlayHook/);
assert.doesNotMatch(main, /createAnthropicComputerHarness|createKimiComputerHarness|createMiniMaxComputerHarness/);
});

Expand All @@ -24,11 +25,17 @@ describe('Desktop Computer Use production wiring', () => {
'utf8',
);
assert.match(main, /sessions:stop[\s\S]*computerUseTools\.clearSession/);
assert.match(main, /sessions:stop[\s\S]*computerUseOverlay\.clearForSession/);
assert.match(main, /sessions:archive[\s\S]*computerUseTools\.clearSession/);
assert.match(main, /sessions:archive[\s\S]*computerUseOverlay\.clearForSession/);
assert.match(main, /sessions:remove[\s\S]*computerUseTools\.clearSession/);
assert.match(main, /sessions:remove[\s\S]*computerUseOverlay\.clearForSession/);
assert.match(main, /isTurnStatusChangingSessionEvent[\s\S]*computerUseTools\.clearSession/);
assert.match(main, /catch \(error\)[\s\S]*computerUseTools\.clearSession/);
assert.match(main, /Promise\.allSettled\(\[[\s\S]*computerUse\.backend\?\.dispose/);
assert.match(main, /Promise\.allSettled\(\[[\s\S]*computerUseOverlay\.destroyAll/);
assert.match(main, /window-all-closed[\s\S]*computerUseOverlay\.destroyAll/);
assert.match(main, /onMainWindowClose = \(\) => computerUseOverlay\.destroyAll/);
});

it('reports scoped approval and live service health instead of binary-only healthy', async () => {
Expand Down
200 changes: 200 additions & 0 deletions apps/desktop/src/main/__tests__/cursor-engine.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Unit tests for the ported agent-cursor engine (palette + Dubins + tick/spring).
// Pure math — no DOM; `paint()` is exercised only by the visual demo. Faithful to
// trycua/cua's cursor-overlay Rust source these were ported from.
import { test } from 'node:test';
import assert from 'node:assert/strict';

import { CursorEngine } from '../../renderer/computer-use-overlay/engine/cursor-engine.js';
import { planDirectPath, planPath } from '../../renderer/computer-use-overlay/engine/dubins.js';
import { paletteForInstance, defaultPalette, gradientAt } from '../../renderer/computer-use-overlay/engine/palette.js';

const finite = (v: number): boolean => Number.isFinite(v);
const REST_HEADING = Math.PI / 4;
const ARROW_TIP_LENGTH = 14;

test('Dubins path primitive remains finite for legacy callers', () => {
const path = planPath(0, 0, 0, 400, 200, Math.PI / 4, Math.PI / 4, 80);
assert.ok(finite(path.length) && path.length > 0, `length ${path.length}`);
const s0 = path.sample(0);
assert.ok(Math.hypot(s0.x, s0.y) < 0.01, 'sample(0) == start');
const sEnd = path.sample(path.length);
assert.ok(Math.hypot(sEnd.x - 400, sEnd.y - 200) < 1.0, `sample(len) ≈ target (err ${Math.hypot(sEnd.x - 400, sEnd.y - 200)})`);
let prev = path.sample(0);
const N = 400;
let maxStep = 0;
for (let i = 1; i <= N; i++) {
const cur = path.sample((path.length * i) / N);
assert.ok(finite(cur.x) && finite(cur.y), 'no NaN along path');
maxStep = Math.max(maxStep, Math.hypot(cur.x - prev.x, cur.y - prev.y));
prev = cur;
}
assert.ok(maxStep < (path.length / N) * 3, `continuity: max step ${maxStep}`);
});

test('direct planner is the shortest path and ends exactly at the target', () => {
const path = planDirectPath(12, 34, 112, 84, REST_HEADING);
assert.ok(Math.abs(path.length - Math.hypot(100, 50)) < 0.001);
const end = path.sample(path.length);
assert.ok(Math.hypot(end.x - 112, end.y - 84) < 0.001);
});

test('speed profile peaks at 1.0 at u=0.5 (smootherstep)', () => {
const u = 0.5;
const profile = (30 * u * u * (1 - u) * (1 - u)) / 1.875;
assert.ok(Math.abs(profile - 1.0) < 1e-9, `profile ${profile}`);
});

test('engine glides directly onto target+offset, no NaN', () => {
const e = new CursorEngine();
e.setSession('conv-test');
const tx = 500, ty = 300;
e.moveTo(tx, ty); // center is offset so the 14px arrow tip lands on (tx, ty)
const offX = tx + Math.cos(REST_HEADING) * ARROW_TIP_LENGTH;
const offY = ty + Math.sin(REST_HEADING) * ARROW_TIP_LENGTH;
let frames = 0;
const dt = 1 / 60;
while (e.isMoving() && frames < 60 * 8) {
e.tick(dt);
assert.ok(finite(e.pos[0]) && finite(e.pos[1]) && finite(e.heading), 'no NaN');
frames++;
}
assert.ok(!e.isMoving(), `settled (frames ${frames})`);
assert.ok(Math.hypot(e.pos[0] - offX, e.pos[1] - offY) < 1.0, 'final pos ≈ target+offset');
assert.ok(frames > 20 && frames < 60 * 6, `glide duration sane (${(frames / 60).toFixed(2)}s)`);
});

test('direct cursor path has no lateral detour or in-flight rotation', () => {
const path = planDirectPath(100, 100, 700, 250, REST_HEADING);
const expectedHeading = Math.atan2(150, 600);
for (let index = 0; index <= 100; index++) {
const point = path.sample((path.length * index) / 100);
const progress = index / 100;
const expectedX = 100 + 600 * progress;
const expectedY = 100 + 150 * progress;
assert.ok(Math.hypot(point.x - expectedX, point.y - expectedY) < 0.01);
assert.ok(Math.abs(point.heading - expectedHeading) < 0.01);
}
});

test('first move glides IN from off-screen (not a pop) and converges to target', () => {
const e = new CursorEngine();
assert.ok(e.pos[0] < -100, 'starts off-screen');
e.moveTo(400, 400);
e.tick(1 / 60);
// Entered on-screen but NOT already at the target — it's gliding in.
assert.ok(e.pos[0] > 0 && e.pos[0] < 400, `entering, still gliding (pos ${e.pos[0]})`);
let frames = 1;
while (e.isMoving() && frames < 600) { e.tick(1 / 60); frames++; }
const tx = 400 + Math.cos(REST_HEADING) * ARROW_TIP_LENGTH;
const ty = 400 + Math.sin(REST_HEADING) * ARROW_TIP_LENGTH;
assert.ok(Math.hypot(e.pos[0] - tx, e.pos[1] - ty) < 1.5, 'converged to target+offset');
});

test('click pulse is centered on the action coordinate, not the arrow body', () => {
const e = new CursorEngine();
const targetX = 320;
const targetY = 240;
e.moveTo(targetX, targetY, undefined, true);
for (let frames = 0; e.isMoving() && frames < 600; frames++) e.tick(1 / 60);
e.triggerClick(targetX, targetY);

const arcs: Array<{ x: number; y: number; radius: number }> = [];
const gradient = { addColorStop() {} };
const ctx = {
createRadialGradient: () => gradient,
createLinearGradient: () => gradient,
beginPath() {},
arc(x: number, y: number, radius: number) { arcs.push({ x, y, radius }); },
fill() {},
stroke() {},
moveTo() {},
lineTo() {},
closePath() {},
set fillStyle(_value: unknown) {},
set strokeStyle(_value: unknown) {},
set lineWidth(_value: number) {},
set lineJoin(_value: CanvasLineJoin) {},
} as unknown as CanvasRenderingContext2D;

e.paint(ctx, 0, 0);
assert.ok(
arcs.some((arc) => Math.hypot(arc.x - targetX, arc.y - targetY) < 0.01),
`click pulse should include action coordinate (${targetX},${targetY}); arcs=${JSON.stringify(arcs)}`,
);
});

test('completion snaps the arrow tip to the executed coordinate and cancels glide', () => {
const e = new CursorEngine();
e.pos = [100, 100];
e.moveTo(500, 300);
e.tick(1 / 60);
e.completeAt(320, 240, true);

const tipX = e.pos[0] - Math.cos(REST_HEADING) * ARROW_TIP_LENGTH;
const tipY = e.pos[1] - Math.sin(REST_HEADING) * ARROW_TIP_LENGTH;
assert.ok(Math.hypot(tipX - 320, tipY - 240) < 0.01);
assert.ok(e.isMoving(), 'pulse remains active after glide is cancelled');
});

test('cursor bloom is centered on the arrow hotspot', () => {
const e = new CursorEngine();
e.completeAt(320, 240);
const gradients: number[][] = [];
const gradient = { addColorStop() {} };
const ctx = {
createRadialGradient: (...args: number[]) => {
gradients.push(args);
return gradient;
},
createLinearGradient: () => gradient,
beginPath() {},
arc() {},
fill() {},
stroke() {},
moveTo() {},
lineTo() {},
closePath() {},
set fillStyle(_value: unknown) {},
set strokeStyle(_value: unknown) {},
set lineWidth(_value: number) {},
set lineJoin(_value: CanvasLineJoin) {},
} as unknown as CanvasRenderingContext2D;

e.paint(ctx, 0, 0);
assert.deepEqual(gradients[0]?.slice(0, 5), [320, 240, 0, 320, 240]);
});

test('direct path planner never detours for short moves', () => {
const cases = [
[100, 100, 120, 120],
[100, 100, 150, 100],
[100, 100, 180, 130],
] as const;
for (const [x0, y0, x1, y1] of cases) {
const direct = Math.hypot(x1 - x0, y1 - y0);
const path = planDirectPath(x0, y0, x1, y1, REST_HEADING);
assert.ok(Math.abs(path.length - direct) < 0.001);
const end = path.sample(path.length);
assert.ok(Math.hypot(end.x - x1, end.y - y1) < 0.001);
}
});

test('click pulse clears over ~0.25s', () => {
const e = new CursorEngine();
e.setSession('x');
e.triggerClick(100, 100);
let ticks = 0;
while (e.isMoving() && ticks < 60) { e.tick(1 / 60); ticks++; }
assert.ok(ticks >= 14 && ticks <= 17, `~0.25s (${ticks} ticks)`);
});

test('palette: deterministic, default→default_blue, varied across ids', () => {
assert.equal(paletteForInstance('run-1').name, paletteForInstance('run-1').name);
assert.equal(paletteForInstance('default').name, 'default_blue');
assert.equal(paletteForInstance('').name, 'default_blue');
const names = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => paletteForInstance(`run-${n}`).name));
assert.ok(names.size >= 5, `varied (${names.size})`);
const g0 = gradientAt(defaultPalette(), 0).join();
const g1 = gradientAt(defaultPalette(), 1).join();
assert.notEqual(g0, g1, 'gradient endpoints differ');
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { test } from 'node:test';

const source = await readFile(
new URL('../../../src/overlay/cursor-overlay-preload.ts', import.meta.url),
'utf8',
);

test('cursor overlay preload exposes only fixed presentation acknowledgements', () => {
assert.match(source, /ipcRenderer\.send\('overlay:presentation-phase'/);
assert.equal(source.match(/ipcRenderer\.send\(/g)?.length, 1);
assert.match(
source,
/ipcRenderer\.send\('overlay:presentation-phase', \{[\s\S]*sessionId,[\s\S]*generation,[\s\S]*actionId,[\s\S]*phase,[\s\S]*\}\)/,
);
assert.match(source, /typeof sessionId !== 'string'/);
assert.match(source, /Number\.isInteger\(generation\)/);
assert.match(source, /phase !== 'readyForInteraction'/);
assert.match(source, /phase !== 'finished'/);
assert.match(source, /ipcRenderer\.on\('overlay:cancel'/);
assert.doesNotMatch(source, /ipcRenderer\.(?:invoke|sendSync)\(/);
assert.doesNotMatch(source, /screenX|screenY|CuAction/);
});
Loading
Loading