Skip to content

Stop the wheel on a Bot's screen from also scrolling the page around it - #603

Merged
davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:driving-wheel-not-passive
Sep 18, 2026
Merged

davidmckayv merged 2 commits into
CopilotKit:mainfrom
kevin9327:driving-wheel-not-passive

Conversation

@kevin9327

@kevin9327 kevin9327 commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

What this changes

While somebody drives a Bot's browser, LiveScreen (app/src/components/computer/live-screen.tsx) forwards a turn of the mouse wheel over the screen, and calls event.preventDefault() so the wheel does not also act on the app. That call was made in React's onWheel, and it does nothing there.

React attaches its wheel handler to the root as a passive listener. In react-dom 19.2 (addTrappedEventListener), touchstart, touchmove and wheel are registered with { passive: true } whenever the browser supports passive listeners, which every current browser does. A browser ignores preventDefault in a passive listener, and Chrome logs a warning each time:

Unable to preventDefault inside passive event listener invocation.

So the wheel reached the Bot's page and also acted on this one:

  • It scrolled whatever on this page was under the screen. In the expanded view that includes the frame holding the screen, which is max-h-[75vh] overflow-auto in computer-view.tsx. Once the screen is taller than that, the screen scrolls away under the pointer while the person is scrolling the Bot's page.
  • Ctrl with the wheel, which is also how a trackpad pinch arrives, zoomed the app, not only the Bot's page.

After this change, the wheel is handled by a listener on the canvas itself, added in an effect with { passive: false } while driving and removed when driving stops. It sends the same message as before. The onWheel prop is removed, so a turn is sent once. at now accepts any { clientX, clientY } rather than only a React mouse event, so the native event can be passed to it. The mouse buttons, context menu and keyboard are untouched.

Where it runs

  • New state that outlives a request? None. This is a DOM listener in the browser.
  • What happens on the second replica? Nothing server-side is involved. The wheel message sent over the live-screen socket is unchanged.
  • Anything serialised? No.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? One wheel listener on the canvas replaces React's onWheel, and it only exists while driving.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. The computer receives the same wheel input as before.
  • New refusals and new failures each write a row. None are added.
  • Nothing new is trusted from the client that the server can resolve itself.

Changelog

  • A line in CHANGELOG.md under Unreleased.

Proof

In Chromium, with React 19.2.0: a page with two overflow: auto frames, each holding a taller canvas. One canvas has onWheel={(e) => e.preventDefault()}, the same pattern as main. The other has a native wheel listener added with { passive: false }, the pattern this PR uses. I sent real wheel input over each through the Browser pane:

React onWheel:            frame scrollTop 0 → 300, event.defaultPrevented false
                          console: "Unable to preventDefault inside passive event listener invocation."
native, passive: false:   frame scrollTop 0 → 0,   event.defaultPrevented true

In the test suite, happy-dom hides the problem: under it, React does not detect passive support and binds wheel actively, so a preventDefault from onWheel does work there. For that reason the new test in app/tests/live-screen-mouse.test.tsx, a turn of the wheel is stopped here as well as sent there, checks the listener the canvas itself holds. It records the options of every wheel listener added to a canvas and expects exactly [{ passive: false }]. It then dispatches a wheel on the canvas and expects the event to be prevented and exactly one wheel message to be sent. It uses a MouseEvent of type wheel, because happy-dom's WheelEvent has no coordinates.

With live-screen.tsx from main (only the test added):

(fail) a turn of the wheel is stopped here as well as sent there
error: expect(received).toEqual(expected)
- [
-   {
-     "passive": false,
-   },
- ]
+ []
 4 pass
 1 fail

With the change, 5 pass, and so do the 9 tests in live-screen-keyboard.test.tsx. bun test --coverage shows every line of the new effect executed, including its cleanup. App suite (bun test app, Windows): 890 pass, 29 fail, the same 29 environment failures main has on this machine. bun run typecheck in app/ is clean, and biome check passes for both files.

This touches the same file as #602, in a different place. The two branches merge cleanly together except for CHANGELOG.md, like every open PR here. I'm happy to rebase whichever lands second.

On Linux too (ubuntu-latest, Bun 1.3.14; a throwaway workflow on my fork, since fork PRs get no CI here): bun test app is 925 pass, 0 fail with all five of my open PRs merged together, and format:check, lint and typecheck are green.

🤖 Generated with Claude Code

LiveScreen forwarded the wheel from React's onWheel and called preventDefault there, so that
scrolling the Bot's page would not also scroll this one. React attaches wheel to its root as a
passive listener (react-dom's addTrappedEventListener, for touchstart, touchmove and wheel whenever
the browser supports passive listeners), and a browser ignores preventDefault in a passive listener.
Chrome says so in the console: "Unable to preventDefault inside passive event listener
invocation."

So while somebody drove a Bot's browser, the wheel reached the Bot's page and also scrolled
whatever on this page was under it. In the expanded view that includes the max-h-[75vh]
overflow-auto frame holding the screen, and Ctrl with the wheel zoomed the app.

The wheel is now handled by a listener on the canvas itself, added with passive: false while
driving, and removed when driving stops. What it sends is unchanged. `at` now takes anything with
clientX and clientY, so a native event can be passed to it as well as React's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@davidmckayv davidmckayv left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code-verified clean; CI green on this sha.

@davidmckayv
davidmckayv merged commit 3a1ec67 into CopilotKit:main Sep 18, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants