feat(host,panel): the agent's cursor over a cloud Browser tab

Merged
DA
4d ago
+1186-59

Part 2 of 2 of the agent's cursor on a cloud Browser tab, which Dean asked for on 2026-10-21 ("like the Codex desktop app"). Part 1 is #4380, which writes the agent's cursor moves to the tab's row and adds the live read live.taskBrowserTabs.

While an agent works on a cloud Browser tab, the panel draws an arrow over the live view:

  • While the agent is active on the tab, the arrow rocks gently, ±6° about its tip over about 1.2 s.
  • On a click or a type, it glides to the target point. A click adds a small press with a ripple, and typing adds a subtle bob.

This is cloud tabs only, behind the Browser tab experiment.

What changed

1. The tab list is followed live (host, gateway, features, panel)

  • Panel: useCloudBrowserTabs reads the tabs once, then follows them over Neon Realtime through the host. This replaces reading them again every 5 s. It uses the same path live.taskPages uses:
    • HostedTaskClient.browserTabsLive asks live.taskBrowserTabs.
    • Gateway method tasks.browserTabsFollow/Unfollow and event tasks.browserTabsChanged.
    • followBrowserTabs in compose-tasks.ts (pushLive → liveRows), cleared on sign-out.
    • The host operator's tasksBrowserTabsFollow and onTaskBrowserTabsChanged.
    • Act kind TASKS_BROWSER_TABS_FOLLOW with its contract and act-port procedure (followedStream), so two panels on one task share one follow.
  • Takeover lapse timer: a takeover lapsing moves no row, so the host tells the rows it last got again at each nextBrowserTabLapse. It uses the Effect clock and a switchMap, so the next delivery cancels the pending timer.
  • No panel poll fallback: followPages has none either. The host's liveRows already polls the read where Realtime is off or fails, and goes back to Realtime when it can.

2. The cursor overlay (packages/panel/src/tasks/cloud-browser-cursor.tsx)

  • Drawing: an SVG arrowhead on a soft halo, drawn by the panel over the canvas, in a stage the size of the canvas. It is never in the frame, takes no pointer events, and is aria-hidden and ph-no-capture.
  • Position: the point is in the page's CSS pixels. It is scaled by cursor.viewport onto the fitted canvas and set as CSS custom properties.
  • Glide: translate moves on --duration-fast (280 ms, the token closest to the 250 ms beat in #4380) with --spring-fast.
  • Rock: an endless loop held by --loop-motion, running only while the agent is active.
  • Click: a press (scale to 0.82 and back) and a ripple. Both are delayed by one glide so they land with the click. Each is keyed by cursor.at, so every click plays afresh.
  • Type: a 1-step bob loop.
  • Idle: the arrow fades 4 s after max(lastActedAt, cursor.at), and fades back in on the next move. The idle timer is an Effect sleep on the renderer runtime, with no raw timers.
  • Hidden while a member drives the tab (from the live view's driver or tab.driver), and while the pane shows a notice ("Waiting for the page", "Paused…").
  • Reduced motion (usePrefersReducedMotion): no rock, bob, press, or ripple, and a move jumps. Separately, the tokens are zeroed under the media query as everywhere else.
  • Tokens only: the panel's --agent-pointer* greys, --luke-space-* sizes, and the motion tokens. The two loop periods are literals, which docs/DESIGN.md allows for loops held by a play-state token.

Decisions to note

  • Look: Codex's, Dean's pick. The arrow is a compact neutral arrowhead, 16 px with a notched tail, on a ~40 px soft neutral halo. It has no accent and no outline. Over a dark page it's drawn in a lighter grey, which the pane reads from an 8×8 patch of the frame under its tip; nothing read is kept. The click ripple is a thin neutral ring.
  • Where the greys live. --agent-pointer, --agent-pointer-on-dark and --agent-pointer-halo are in the panel's base.css, not @luke/ui, because palettes.css keeps a colour only one surface spends in that app's stylesheet.
  • Idle and active bound. Both are 4 s (CLOUD_BROWSER_CURSOR_IDLE). The bar's "Luke is using this tab" keeps its own 15 s bound.
  • Loading states. "Waiting for the page" and "Reconnecting" show the panel's shared region Spinner, with the words as its screen-reader label. The two ended states stay as text.
  • A broken follow (refused, or ended) is read and followed again every 5 s (CLOUD_BROWSER_TABS_AGAIN).
  • At the right and bottom edges the arrow mirrors about its tip, so it stays on the page.

Tests

  • compose-tasks.test.ts: a follow pushes the tabs as they move, with the agent's pointer among them. It pushes them again as a takeover lapses with no row moving, never in the view, and stops when let go.
  • tasks-procedures.test.ts: the follow is streamed to a panel, scoped to its task, and let go as the stream closes. The voice window is refused.
  • cloud-browser-tab.test.ts:
    • the tabs are read once, then followed;
    • a move the follow tells is drawn without another read, and the tabs are never read again on a beat;
    • a failure keeps the list;
    • the follow is let go once no reader stands.
  • cloud-browser-cursor.test.ts (jsdom, pinned clock):
    • the arrow stands at the scaled point (640,300 on a 1280×800 page shows at 320,150 on a 640×400 frame) and moves with the agent, out of the recording and of the pointer's way;
    • it hides during a takeover and comes back on hand-back;
    • it fades once idle;
    • reduced motion holds it still.

Local runs:

  • tsc passes for features, gateway, host, panel, apps/web and apps/desktop.
  • oxlint and biome are clean.
  • The four test files pass (117 tests).
  • pnpm check:affected was OOM-killed in this sandbox, so CI is the full gate.

Motion checked by sampling with requestAnimationFrame in Chromium:

  • the glide lands in about 280 ms with the spring's slight overshoot;
  • the press bottoms out at 0.82 around 400 ms after the click;
  • the rock sweeps ±6°.

Screenshots

A throwaway harness, not committed, mounted CloudBrowserPane over a fake live feed. It ran in Chromium with the web (light) and desktop (dark) profiles, and in Electron under Xvfb. Media is on pr-assets under pr-<this PR>/ and is linked in a comment below.


Devin Review

Made with Luke