import { ooAvatarColor, screenPointInViewer } from "@rakazo/core"; import { Keyboard } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { agentPointerFeed, POINTER_IDLE_MS, type PointerPoint, type PointerScreen, type PointerSegment, planPointerTimeline, pointerFrameAt, } from "../../lib/agent-pointer"; function prefersReducedMotion() { return ( typeof window.matchMedia === "function" && window.matchMedia("(prefers-reduced-motion: reduce)").matches ); } /** * Draws a bot's announced pointer over its live screen: it glides to each target, ripples on * clicks and shows when it types. Frames are written straight to the DOM so the chat never * re-renders at animation rate. */ export function AgentCursor({ botId, color, name, }: { botId: string; color: string; name: string; }) { const layerRef = useRef(null); const cursorRef = useRef(null); const rippleRef = useRef(null); const [typing, setTyping] = useState(false); const tint = ooAvatarColor(color) || undefined; useEffect(() => { const layer = layerRef.current; const cursor = cursorRef.current; const ripple = rippleRef.current; if (!layer || !cursor || !ripple) return; const reducedMotion = prefersReducedMotion(); let screen: PointerScreen | null = null; let segments: PointerSegment[] = []; let position: PointerPoint | null = null; let frame = 0; let idleTimer: ReturnType | undefined; let viewer = { width: layer.clientWidth, height: layer.clientHeight }; let shownTyping = false; const place = (point: PointerPoint, action: string | null, progress: number) => { if (!screen) return; const projected = screenPointInViewer(point, viewer, screen); cursor.style.transform = `translate3d(${projected.x}px, ${projected.y}px, 0)`; cursor.dataset.action = action ?? "move"; const pressing = action === "click" || action === "double_click" || action === "right_click"; const pulse = action === "double_click" ? (progress * 2) % 1 : progress; ripple.style.opacity = pressing && !reducedMotion ? String(0.55 * (1 - pulse)) : "0"; ripple.style.transform = `scale(${0.35 + pulse * 1.15})`; cursor.style.setProperty("--agent-cursor-press", pressing && pulse < 0.35 ? "0.86" : "1"); const nextTyping = action === "type" || action === "key"; if (nextTyping !== shownTyping) { shownTyping = nextTyping; setTyping(nextTyping); } }; const rest = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { cursor.style.opacity = "0"; }, POINTER_IDLE_MS); }; const tick = () => { frame = 0; const now = performance.now(); const state = pointerFrameAt(segments, now, reducedMotion); if (!state) return; position = state.point; place(state.point, state.action?.kind ?? null, state.action?.progress ?? 0); if (state.done) { segments = []; rest(); return; } frame = requestAnimationFrame(tick); }; const unsubscribe = agentPointerFeed.subscribe(botId, (payload) => { const now = performance.now(); const current = pointerFrameAt(segments, now, reducedMotion)?.point ?? position; const sameScreen = screen?.width === payload.screen.width && screen?.height === payload.screen.height; screen = payload.screen; // A first announcement starts at its target rather than flying in from a corner. const firstTarget = payload.steps.find((step) => typeof step.x === "number"); const from = current && sameScreen ? current : firstTarget ? { x: firstTarget.x ?? 0, y: firstTarget.y ?? 0 } : { x: payload.screen.width / 2, y: payload.screen.height / 2 }; segments = planPointerTimeline(from, payload.steps, now); clearTimeout(idleTimer); cursor.style.opacity = "1"; if (!frame) frame = requestAnimationFrame(tick); }); const resize = new ResizeObserver(() => { viewer = { width: layer.clientWidth, height: layer.clientHeight }; if (position && !frame) place(position, null, 0); }); resize.observe(layer); return () => { unsubscribe(); resize.disconnect(); cancelAnimationFrame(frame); clearTimeout(idleTimer); }; }, [botId]); return (
{name} {typing ? : null}
); }