Skip to content

Commit 1b4cfa5

Browse files
feat(preview): show the agent's cursor on server-browser tabs
Server tabs showed a page changing with no sign of where the agent acted, which made agent clicks, hovers, and drags hard to follow. The server now sends viewers the agent's pointer before each click, hover, and drag lands, gliding to the target and pulsing on a click like desktop tabs. Web draws it with the desktop cursor mark, mobile draws it in the stream viewer, and recordings now show hovers and drags too. Unwatched tabs skip the glide delay. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent a530815 commit 1b4cfa5

11 files changed

Lines changed: 315 additions & 27 deletions

File tree

‎apps/mobile/src/features/browser/preview-stream-document.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,21 @@ export function previewStreamDocument(configuration: string, script: string) {
4848
canvas, video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
4949
canvas { touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; }
5050
video { pointer-events: none; }
51+
/* The agent's pointer, positioned over the letterboxed frame by the viewer script. */
52+
.agent-cursor {
53+
position: absolute; left: 0; top: 0; width: 20px; height: 20px; pointer-events: none;
54+
opacity: 0; transition: opacity 150ms ease-out, transform 150ms ease-out;
55+
}
56+
.agent-cursor svg { position: relative; display: block; filter: drop-shadow(0 1px 1px rgba(0,0,0,.35)); }
57+
.agent-cursor .ping {
58+
position: absolute; left: 2px; top: 2px; width: 16px; height: 16px; border-radius: 50%;
59+
background: rgba(59,130,246,.3); animation: agent-cursor-ping 600ms ease-out forwards;
60+
}
61+
@keyframes agent-cursor-ping { from { transform: scale(.6); opacity: 1; } to { transform: scale(2.2); opacity: 0; } }
62+
@media (prefers-reduced-motion: reduce) {
63+
.agent-cursor { transition: none; }
64+
.agent-cursor .ping { animation: none; opacity: 0; }
65+
}
5166
/* Pinned so focus never scrolls; 16px keeps iOS from zooming on focus. */
5267
textarea {
5368
position: fixed; left: 0; top: 0; width: 1px; height: 1px;

‎apps/mobile/src/features/browser/preview-stream.browser.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type PreviewStreamClient,
66
type PreviewStreamControl,
77
type PreviewStreamInput,
8+
type PreviewStreamPointer,
89
type PreviewStreamViewport,
910
} from "@t3tools/client-runtime/preview/server-browser-stream";
1011

@@ -41,6 +42,10 @@ const PROBE_REUSE_MS = 10_000;
4142
// Kept in the hidden textarea so a soft keyboard's backspace has something to
4243
// delete and fires `input`; Gboard's keydown carries keyCode 229 and no key.
4344
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>';
4449

4550
interface Viewer {
4651
readonly stop: () => void;
@@ -76,7 +81,11 @@ export function start(configuration: PreviewStreamConfiguration) {
7681
const canvas = document.createElement("canvas");
7782
canvas.setAttribute("role", "img");
7883
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);
8089
const input = document.createElement("textarea");
8190
input.setAttribute("aria-label", "Browser page input");
8291
input.autocapitalize = "off";
@@ -170,6 +179,7 @@ export function start(configuration: PreviewStreamConfiguration) {
170179
},
171180
{
172181
onFrame: (jpeg) => painter.paint(jpeg),
182+
onPointer: showAgentCursor,
173183
onClipboard: (text) => post({ type: "clipboard", text }),
174184
onDownload: (download) => post({ type: "download", ...download }),
175185
onFileChooser: (chooser) => post({ type: "fileChooser", chooser }),
@@ -204,6 +214,8 @@ export function start(configuration: PreviewStreamConfiguration) {
204214
const previous = control;
205215
control = nextControl;
206216
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";
207219
if (nextControl.controller !== "you") clearInput();
208220
else {
209221
input.disabled = !interactive;
@@ -263,6 +275,31 @@ export function start(configuration: PreviewStreamConfiguration) {
263275
visualViewport?.addEventListener("scroll", followVisualViewport);
264276
}
265277

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+
266303
const pagePoint = (clientX: number, clientY: number, clamp: boolean) => {
267304
if (!viewport || canvas.width === 0 || canvas.height === 0 || control?.controller !== "you")
268305
return null;
@@ -647,6 +684,7 @@ export function start(configuration: PreviewStreamConfiguration) {
647684
if (resizeTimer !== null) clearTimeout(resizeTimer);
648685
if (wheelFrame !== null) cancelAnimationFrame(wheelFrame);
649686
if (mouseFrame !== null) cancelAnimationFrame(mouseFrame);
687+
if (agentCursorTimer !== null) clearTimeout(agentCursorTimer);
650688
painter.stop();
651689
client?.stop();
652690
client = null;

‎apps/server/src/preview/ServerBrowser.test.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) {
6969
let contextClosed = false;
7070
const page = {
7171
on: (name: string, callback: (...args: unknown[]) => void) => events.on(name, callback),
72+
once: (name: string, callback: (...args: unknown[]) => void) => events.once(name, callback),
73+
off: (name: string, callback: (...args: unknown[]) => void) => events.off(name, callback),
7274
emit: (name: string, ...args: unknown[]) => events.emit(name, ...args),
7375
emitAsync: (name: string, ...args: unknown[]) =>
7476
Promise.all(events.listeners(name).map((listener) => listener(...args))),
@@ -840,3 +842,28 @@ it.live("an agent answers the page's file picker or sets files on a file input",
840842
}),
841843
).pipe(Effect.provide(layer)),
842844
);
845+
846+
it.live("viewers see the agent's pointer move to its target and click there", () =>
847+
Effect.scoped(
848+
Effect.gen(function* () {
849+
const { browser, broker, tabId } = yield* ready;
850+
const page = contexts[0]!.page;
851+
const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
852+
const click = vi.fn(async () => {});
853+
page.locator.mockReturnValue({
854+
scrollIntoViewIfNeeded: async () => {},
855+
boundingBox: async () => ({ x: 100, y: 40, width: 80, height: 20 }),
856+
click,
857+
} as never);
858+
yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "#go" } });
859+
const pointers = (yield* Queue.clear(viewer.output)).filter(
860+
(item) => item._tag === "pointer",
861+
);
862+
expect(pointers).toEqual([
863+
expect.objectContaining({ phase: "move", x: 140, y: 50 }),
864+
expect.objectContaining({ phase: "click", x: 140, y: 50 }),
865+
]);
866+
expect(click).toHaveBeenCalledOnce();
867+
}),
868+
).pipe(Effect.provide(layer)),
869+
);

‎apps/server/src/preview/ServerBrowser.ts‎

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,9 @@ const HOST_RECONNECT_DELAY = "1 second";
8282
const VIEWER_OUTPUT_LIMIT = 64;
8383
const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const;
8484
const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema);
85+
/** The agent cursor glides to its target, then pulses just before the press, like desktop tabs. */
86+
const AGENT_CURSOR_MOVE_MS = 160;
87+
const AGENT_CURSOR_CLICK_LEAD_MS = 40;
8588

8689
const sleepUntil = (deadline: number) =>
8790
new Promise<void>((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now())));
@@ -153,6 +156,13 @@ export type ServerBrowserViewerOutput =
153156
readonly editable: boolean;
154157
}
155158
| { readonly _tag: "clipboard"; readonly text: string }
159+
| {
160+
readonly _tag: "pointer";
161+
readonly phase: "move" | "click";
162+
readonly x: number;
163+
readonly y: number;
164+
readonly sequence: number;
165+
}
156166
| {
157167
readonly _tag: "fileChooser";
158168
readonly id: string;
@@ -1187,6 +1197,35 @@ const make = Effect.gen(function* () {
11871197
};
11881198
});
11891199

1200+
let pointerSequence = 0;
1201+
/**
1202+
* Shows viewers and recordings where the agent is about to act. Nobody is
1203+
* watching a headless agent tab, so it pays no glide delay.
1204+
*/
1205+
const pointerFor =
1206+
(tab: ServerTab): ServerBrowserPage.PointerReporter =>
1207+
async ({ x, y }, phase) => {
1208+
const encoder = tab.recording?.encoder;
1209+
if (tab.viewers.size === 0 && !encoder) return;
1210+
const show = (next: "move" | "click") => {
1211+
const sequence = ++pointerSequence;
1212+
for (const viewer of tab.viewers)
1213+
viewer.push({ _tag: "pointer", phase: next, x, y, sequence });
1214+
void encoder
1215+
?.evaluate(
1216+
([px, py, click]) =>
1217+
(globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(px, py, click),
1218+
[x, y, next === "click"] as const,
1219+
)
1220+
.catch(constVoid);
1221+
};
1222+
show("move");
1223+
await sleepUntil(Date.now() + AGENT_CURSOR_MOVE_MS);
1224+
if (phase !== "click") return;
1225+
show("click");
1226+
await sleepUntil(Date.now() + AGENT_CURSOR_CLICK_LEAD_MS);
1227+
};
1228+
11901229
const recordAction = <A>(tab: ServerTab, action: string, run: () => Promise<A>): Promise<A> => {
11911230
const event: {
11921231
-readonly [K in keyof PreviewAutomationActionEvent]: PreviewAutomationActionEvent[K];
@@ -1454,28 +1493,22 @@ const make = Effect.gen(function* () {
14541493
}
14551494
case "click": {
14561495
const clickInput = input as PreviewAutomationClickInput;
1457-
const point = await recordAction(tab, "click", () =>
1458-
ServerBrowserPage.click(tab.page, clickInput),
1496+
await recordAction(tab, "click", () =>
1497+
ServerBrowserPage.click(tab.page, clickInput, pointerFor(tab)),
14591498
);
1460-
void tab.recording?.encoder
1461-
.evaluate(
1462-
([x, y]) => (globalThis as unknown as EncoderWindow).__t3Recorder?.cursor(x, y, true),
1463-
[point.x, point.y] as const,
1464-
)
1465-
.catch(constVoid);
14661499
return undefined;
14671500
}
14681501
case "hover":
14691502
return recordAction(tab, "hover", () =>
1470-
ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput),
1503+
ServerBrowserPage.hover(tab.page, input as PreviewAutomationHoverInput, pointerFor(tab)),
14711504
);
14721505
case "select":
14731506
return recordAction(tab, "select", () =>
14741507
ServerBrowserPage.select(tab.page, input as PreviewAutomationSelectInput),
14751508
);
14761509
case "drag":
14771510
return recordAction(tab, "drag", () =>
1478-
ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput),
1511+
ServerBrowserPage.drag(tab.page, input as PreviewAutomationDragInput, pointerFor(tab)),
14791512
);
14801513
case "upload":
14811514
return recordAction(tab, "upload", () =>

‎apps/server/src/preview/ServerBrowserPage.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,35 @@ describe("server browser element refs", () => {
214214
ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }),
215215
).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" });
216216
});
217+
218+
it("shows the agent's pointer at each target before the action reaches the page", async () => {
219+
await page.setContent(`
220+
<button id="go" style="position:absolute;left:100px;top:40px;width:80px;height:20px">go</button>
221+
<div id="tip" style="position:absolute;left:300px;top:40px;width:60px;height:20px">tip</div>
222+
<div id="card" draggable="true" style="position:absolute;left:20px;top:120px;width:40px;height:40px">card</div>
223+
<div id="lane" style="position:absolute;left:220px;top:120px;width:100px;height:40px">lane</div>
224+
<script>
225+
window.seen = [];
226+
go.onclick = () => seen.push("click");
227+
tip.onmouseenter = () => seen.push("hover");
228+
card.ondragstart = (event) => event.dataTransfer.setData("text/plain", "card");
229+
lane.ondragover = (event) => event.preventDefault();
230+
lane.ondrop = () => seen.push("drop");
231+
</script>`);
232+
const shown: Array<string> = [];
233+
const pointer: ServerBrowserPage.PointerReporter = async ({ x, y }, phase) => {
234+
const seen = await page.evaluate("window.seen.length");
235+
shown.push(`${phase}@${Math.round(x)},${Math.round(y)} after ${seen}`);
236+
};
237+
await ServerBrowserPage.click(page, { locator: "#go" }, pointer);
238+
await ServerBrowserPage.hover(page, { locator: "#tip" }, pointer);
239+
await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }, pointer);
240+
expect(shown).toEqual([
241+
"click@140,50 after 0",
242+
"move@330,50 after 1",
243+
"move@40,140 after 2",
244+
"move@270,140 after 2",
245+
]);
246+
expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);
247+
});
217248
});

‎apps/server/src/preview/ServerBrowserPage.ts‎

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,28 @@ const targetLocator = (
118118
return page.locator(selector);
119119
};
120120

121+
/** Shows the agent's pointer to viewers before an action lands at that point. */
122+
export type PointerReporter = (
123+
point: { readonly x: number; readonly y: number },
124+
phase: "move" | "click",
125+
) => Promise<void>;
126+
const noPointer: PointerReporter = async () => {};
127+
128+
/** The viewport point an action will hit: the target's center after scrolling it into view. */
129+
const targetPoint = async (
130+
page: Page,
131+
locator: Locator | null,
132+
input: { readonly x?: number | undefined; readonly y?: number | undefined },
133+
timeout: number,
134+
) => {
135+
if (locator === null) return { x: input.x ?? 0, y: input.y ?? 0 };
136+
await locator.scrollIntoViewIfNeeded({ timeout });
137+
const box = await locator.boundingBox({ timeout });
138+
if (box) return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
139+
const viewport = page.viewportSize();
140+
return { x: (viewport?.width ?? 0) / 2, y: (viewport?.height ?? 0) / 2 };
141+
};
142+
121143
const SNAPSHOT_SCRIPT = `(() => {
122144
return {
123145
url: location.href,
@@ -211,15 +233,12 @@ export const snapshot = async (input: {
211233
export const click = async (
212234
page: Page,
213235
input: PreviewAutomationClickInput,
236+
pointer: PointerReporter = noPointer,
214237
): Promise<{ readonly x: number; readonly y: number }> => {
215238
const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
216239
const locator = targetLocator(page, input);
217-
let point = { x: input.x ?? 0, y: input.y ?? 0 };
218-
if (locator !== null) {
219-
await locator.scrollIntoViewIfNeeded({ timeout });
220-
const box = await locator.boundingBox({ timeout });
221-
if (box) point = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
222-
}
240+
const point = await targetPoint(page, locator, input, timeout);
241+
await pointer(point, "click");
223242
const options = { button: input.button ?? "left", clickCount: input.clickCount ?? 1 } as const;
224243
const clicked =
225244
locator === null
@@ -301,13 +320,19 @@ export const type = async (page: Page, input: PreviewAutomationTypeInput) => {
301320
};
302321

303322
/** Hovering by locator moves the real mouse, so CSS :hover and pointer events both apply. */
304-
export const hover = async (page: Page, input: PreviewAutomationHoverInput) => {
323+
export const hover = async (
324+
page: Page,
325+
input: PreviewAutomationHoverInput,
326+
pointer: PointerReporter = noPointer,
327+
) => {
328+
const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
305329
const locator = targetLocator(page, input);
330+
await pointer(await targetPoint(page, locator, input, timeout), "move");
306331
if (locator === null) {
307332
await page.mouse.move(input.x ?? 0, input.y ?? 0);
308333
return;
309334
}
310-
await locator.hover({ timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
335+
await locator.hover({ timeout });
311336
};
312337

313338
export const select = async (
@@ -346,10 +371,20 @@ export const select = async (
346371
return { selected: await locator.selectOption(values, { timeout }) };
347372
};
348373

349-
export const drag = async (page: Page, input: PreviewAutomationDragInput) => {
374+
export const drag = async (
375+
page: Page,
376+
input: PreviewAutomationDragInput,
377+
pointer: PointerReporter = noPointer,
378+
) => {
379+
const timeout = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
350380
const source = targetLocator(page, { locator: input.source })!;
351381
const target = targetLocator(page, { locator: input.target })!;
352-
await source.dragTo(target, { timeout: input.timeoutMs ?? DEFAULT_TIMEOUT_MS });
382+
await pointer(await targetPoint(page, source, {}, timeout), "move");
383+
// The cursor travels with the drag; the page sees one continuous gesture.
384+
const dropped = source.dragTo(target, { timeout });
385+
const end = await target.boundingBox({ timeout }).catch(() => null);
386+
if (end) await pointer({ x: end.x + end.width / 2, y: end.y + end.height / 2 }, "move");
387+
await dropped;
353388
};
354389

355390
/** Sets files on one file input; false when no locator or selector names one. */

‎apps/server/src/preview/ServerBrowserStream.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ const makeHandler = (browser: ServerBrowser.ServerBrowser["Service"]) =>
135135
case "download":
136136
case "fileChooser":
137137
case "fileChooserClosed":
138+
case "pointer":
138139
case "probe": {
139140
const { _tag: type, ...data } = output;
140141
return write(JSON.stringify({ type, ...data }));

0 commit comments

Comments
 (0)