import { type ComputerPointerAction, ComputerPointerPayload, type ComputerPointerStep, } from "@rakazo/contracts"; import { POINTER_EVENT_MAX_AGE_MS } from "@rakazo/core"; export type PointerPoint = { x: number; y: number }; export type PointerScreen = ComputerPointerPayload["screen"]; export { POINTER_EVENT_MAX_AGE_MS }; export const POINTER_IDLE_MS = 3_500; type Listener = (payload: ComputerPointerPayload) => void; /** * Live pointer announcements per bot. The thread subscription publishes `thread.computer` * pointer events here; screen viewers subscribe for the bot they show. */ class AgentPointerFeed { private readonly listeners = new Map>(); publish(botId: string, payload: unknown, createdAt?: string, now = Date.now()): boolean { const parsed = ComputerPointerPayload.safeParse(payload); if (!parsed.success) return false; const at = createdAt ? Date.parse(createdAt) : Number.NaN; if (Number.isFinite(at) && now - at > POINTER_EVENT_MAX_AGE_MS) return false; for (const listener of this.listeners.get(botId) ?? []) listener(parsed.data); return true; } subscribe(botId: string, listener: Listener): () => void { let set = this.listeners.get(botId); if (!set) { set = new Set(); this.listeners.set(botId, set); } set.add(listener); return () => { set.delete(listener); if (set.size === 0) this.listeners.delete(botId); }; } } export const agentPointerFeed = new AgentPointerFeed(); export type PointerSegment = { from: PointerPoint; to: PointerPoint; /** Perpendicular bow in screen pixels so long moves read as a hand, not a teleport. */ bow: number; start: number; arrive: number; end: number; action: ComputerPointerAction; }; const ACTION_DWELL_MS: Record = { move: 0, click: 320, double_click: 440, right_click: 320, down: 160, up: 160, type: 700, key: 280, scroll: 420, }; export function moveDurationMs(distance: number): number { return distance < 1 ? 0 : Math.round(Math.min(650, Math.max(180, 140 + distance * 0.45))); } /** * Lays announced steps on a timeline that starts where the cursor currently is. Keyboard * steps carry no coordinates and act in place. */ export function planPointerTimeline( from: PointerPoint, steps: readonly ComputerPointerStep[], startAt: number, ): PointerSegment[] { const segments: PointerSegment[] = []; let position = from; let time = startAt; for (const step of steps) { const to = typeof step.x === "number" && typeof step.y === "number" ? { x: step.x, y: step.y } : position; const distance = Math.hypot(to.x - position.x, to.y - position.y); const arrive = time + moveDurationMs(distance); const end = arrive + ACTION_DWELL_MS[step.action]; segments.push({ from: position, to, bow: Math.min(48, distance * 0.12), start: time, arrive, end, action: step.action, }); position = to; time = end; } return segments; } export type PointerFrame = { point: PointerPoint; /** The action being performed at the target, with 0..1 progress; null while travelling. */ action: { kind: ComputerPointerAction; progress: number } | null; done: boolean; }; function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; } export function pointerFrameAt( segments: readonly PointerSegment[], now: number, reducedMotion = false, ): PointerFrame | null { if (segments.length === 0) return null; const last = segments[segments.length - 1]!; if (now >= last.end) return { point: last.to, action: null, done: true }; const segment = segments.find((candidate) => now < candidate.end) ?? last; if (now < segment.arrive) { if (reducedMotion) return { point: segment.to, action: null, done: false }; const span = segment.arrive - segment.start; const t = easeInOutCubic(span > 0 ? Math.max(0, (now - segment.start) / span) : 1); const dx = segment.to.x - segment.from.x; const dy = segment.to.y - segment.from.y; const length = Math.hypot(dx, dy) || 1; const lift = Math.sin(Math.PI * t) * segment.bow; return { point: { x: segment.from.x + dx * t - (dy / length) * lift, y: segment.from.y + dy * t + (dx / length) * lift, }, action: null, done: false, }; } const dwell = segment.end - segment.arrive; return { point: segment.to, action: segment.action === "move" ? null : { kind: segment.action, progress: dwell > 0 ? (now - segment.arrive) / dwell : 1 }, done: false, }; }