|
5 | 5 | type PreviewStreamClient, |
6 | 6 | type PreviewStreamControl, |
7 | 7 | type PreviewStreamInput, |
| 8 | + type PreviewStreamPointer, |
8 | 9 | type PreviewStreamViewport, |
9 | 10 | } from "@t3tools/client-runtime/preview/server-browser-stream"; |
10 | 11 |
|
@@ -41,6 +42,10 @@ const PROBE_REUSE_MS = 10_000; |
41 | 42 | // Kept in the hidden textarea so a soft keyboard's backspace has something to |
42 | 43 | // delete and fires `input`; Gboard's keydown carries keyCode 229 and no key. |
43 | 44 | const SENTINEL = "\u200b"; |
| 45 | +// The agent cursor stays bright while the agent acts, then dims like the web panel's. |
| 46 | +const AGENT_CURSOR_ACTIVE_MS = 700; |
| 47 | +const AGENT_CURSOR_SVG = |
| 48 | + '<svg width="20" height="20" viewBox="0 0 24 24" fill="#fff" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"/></svg>'; |
44 | 49 |
|
45 | 50 | interface Viewer { |
46 | 51 | readonly stop: () => void; |
@@ -76,7 +81,11 @@ export function start(configuration: PreviewStreamConfiguration) { |
76 | 81 | const canvas = document.createElement("canvas"); |
77 | 82 | canvas.setAttribute("role", "img"); |
78 | 83 | canvas.setAttribute("aria-label", "Browser page"); |
79 | | - container.append(canvas); |
| 84 | + const agentCursor = document.createElement("div"); |
| 85 | + agentCursor.className = "agent-cursor"; |
| 86 | + agentCursor.setAttribute("aria-hidden", "true"); |
| 87 | + agentCursor.innerHTML = AGENT_CURSOR_SVG; |
| 88 | + container.append(canvas, agentCursor); |
80 | 89 | const input = document.createElement("textarea"); |
81 | 90 | input.setAttribute("aria-label", "Browser page input"); |
82 | 91 | input.autocapitalize = "off"; |
@@ -170,6 +179,7 @@ export function start(configuration: PreviewStreamConfiguration) { |
170 | 179 | }, |
171 | 180 | { |
172 | 181 | onFrame: (jpeg) => painter.paint(jpeg), |
| 182 | + onPointer: showAgentCursor, |
173 | 183 | onClipboard: (text) => post({ type: "clipboard", text }), |
174 | 184 | onDownload: (download) => post({ type: "download", ...download }), |
175 | 185 | onFileChooser: (chooser) => post({ type: "fileChooser", chooser }), |
@@ -204,6 +214,8 @@ export function start(configuration: PreviewStreamConfiguration) { |
204 | 214 | const previous = control; |
205 | 215 | control = nextControl; |
206 | 216 | post({ type: "control", ...nextControl }); |
| 217 | + // Taking over hides the agent cursor; the person's own touch is the pointer now. |
| 218 | + if (nextControl.controller === "you") agentCursor.style.opacity = "0"; |
207 | 219 | if (nextControl.controller !== "you") clearInput(); |
208 | 220 | else { |
209 | 221 | input.disabled = !interactive; |
@@ -263,6 +275,31 @@ export function start(configuration: PreviewStreamConfiguration) { |
263 | 275 | visualViewport?.addEventListener("scroll", followVisualViewport); |
264 | 276 | } |
265 | 277 |
|
| 278 | + let agentCursorTimer: ReturnType<typeof setTimeout> | null = null; |
| 279 | + /** Mirrors the frame's letterbox so the cursor lands on the element the agent targets. */ |
| 280 | + const showAgentCursor = (pointer: PreviewStreamPointer) => { |
| 281 | + if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller === "you") |
| 282 | + return; |
| 283 | + const box = { width: canvas.clientWidth, height: canvas.clientHeight }; |
| 284 | + const fit = Math.min(box.width / canvas.width, box.height / canvas.height); |
| 285 | + const width = canvas.width * fit; |
| 286 | + const height = canvas.height * fit; |
| 287 | + const left = (box.width - width) / 2 + (pointer.x * width) / viewport.width; |
| 288 | + const top = (box.height - height) / 2 + (pointer.y * height) / viewport.height; |
| 289 | + agentCursor.style.transform = `translate3d(${left}px, ${top}px, 0)`; |
| 290 | + agentCursor.style.opacity = "1"; |
| 291 | + if (pointer.phase === "click") { |
| 292 | + agentCursor.querySelector(".ping")?.remove(); |
| 293 | + const ping = document.createElement("span"); |
| 294 | + ping.className = "ping"; |
| 295 | + agentCursor.prepend(ping); |
| 296 | + } |
| 297 | + if (agentCursorTimer !== null) clearTimeout(agentCursorTimer); |
| 298 | + agentCursorTimer = setTimeout(() => { |
| 299 | + agentCursor.style.opacity = control?.controller === "agent" ? "0.35" : "0"; |
| 300 | + }, AGENT_CURSOR_ACTIVE_MS); |
| 301 | + }; |
| 302 | + |
266 | 303 | const pagePoint = (clientX: number, clientY: number, clamp: boolean) => { |
267 | 304 | if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller !== "you") |
268 | 305 | return null; |
@@ -647,6 +684,7 @@ export function start(configuration: PreviewStreamConfiguration) { |
647 | 684 | if (resizeTimer !== null) clearTimeout(resizeTimer); |
648 | 685 | if (wheelFrame !== null) cancelAnimationFrame(wheelFrame); |
649 | 686 | if (mouseFrame !== null) cancelAnimationFrame(mouseFrame); |
| 687 | + if (agentCursorTimer !== null) clearTimeout(agentCursorTimer); |
650 | 688 | painter.stop(); |
651 | 689 | client?.stop(); |
652 | 690 | client = null; |
|
0 commit comments