feat(host,panel): the agent's cursor over a cloud Browser tab
Merged
DA
4d ago+1186+1186+0123456789012345678901234567890123456789-59-59-01234567890123456789
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:
useCloudBrowserTabsreads the tabs once, then follows them over Neon Realtime through the host. This replaces reading them again every 5 s. It uses the same pathlive.taskPagesuses:HostedTaskClient.browserTabsLiveaskslive.taskBrowserTabs.- Gateway method
tasks.browserTabsFollow/Unfollowand eventtasks.browserTabsChanged. followBrowserTabsincompose-tasks.ts(pushLive→liveRows), cleared on sign-out.- The host operator's
tasksBrowserTabsFollowandonTaskBrowserTabsChanged. - Act kind
TASKS_BROWSER_TABS_FOLLOWwith 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 aswitchMap, so the next delivery cancels the pending timer. - No panel poll fallback:
followPageshas none either. The host'sliveRowsalready 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-hiddenandph-no-capture. - Position: the point is in the page's CSS pixels. It is scaled by
cursor.viewportonto the fitted canvas and set as CSS custom properties. - Glide:
translatemoves 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, whichdocs/DESIGN.mdallows 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-darkand--agent-pointer-haloare in the panel'sbase.css, not@luke/ui, becausepalettes.csskeeps 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:
tscpasses forfeatures,gateway,host,panel,apps/webandapps/desktop.oxlintandbiomeare clean.- The four test files pass (117 tests).
pnpm check:affectedwas 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.
Made with Luke