diff --git a/packages/app/src/home/route.tsx b/packages/app/src/home/route.tsx
index 188210be169..6626e90f8f7 100644
--- a/packages/app/src/home/route.tsx
+++ b/packages/app/src/home/route.tsx
@@ -37,7 +37,6 @@ export function Home() {
thumbHoverTarget={scroll.viewport.hoverTarget()}
viewportRef={scroll.viewport.setViewport}
onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)}
- onWheel={scroll.viewport.containOuterWheel}
>
)
},
update,
containWheel,
- containOuterWheel: (event: WheelEvent) => {
- if (!viewport) return
-
- if (event.target instanceof Node && viewport.contains(event.target)) return
- containWheel(event)
- },
},
header: {
setContent: (element: HTMLDivElement) => {
diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx
index d5916634f61..4e0993781c5 100644
--- a/packages/app/src/session/screen.tsx
+++ b/packages/app/src/session/screen.tsx
@@ -355,7 +355,7 @@ function SessionScreenContent(props: {
data-scrollbar-hidden={store.timelineScrollbarHidden || store.sideWidthMotion}
onPointerMove={revealTimelineScrollbar}
onPointerDown={revealTimelineScrollbar}
- onWheel={revealTimelineScrollbar}
+ on:wheel={{ passive: true, handleEvent: revealTimelineScrollbar }}
onKeyDown={revealTimelineScrollbar}
onTransitionRun={trackSideWidthMotion}
onTransitionEnd={trackSideWidthMotion}
diff --git a/packages/desktop/src/main/lifecycle/configure.ts b/packages/desktop/src/main/lifecycle/configure.ts
index 878d184a4f0..488eca0fe3a 100644
--- a/packages/desktop/src/main/lifecycle/configure.ts
+++ b/packages/desktop/src/main/lifecycle/configure.ts
@@ -26,6 +26,9 @@ export function configureApplication() {
const features = app.commandLine.getSwitchValue("enable-features")
app.commandLine.appendSwitch("enable-features", features ? `${jsCallStackFeature},${features}` : jsCallStackFeature)
+ // Chromium animates each mouse-wheel notch on Linux; native Linux apps scroll by the full delta immediately.
+ if (process.platform === "linux") app.commandLine.appendSwitch("disable-smooth-scrolling")
+
if (!app.isPackaged)
app.commandLine.appendSwitch("remote-debugging-port", process.env.OPENCODE_DESKTOP_REMOTE_DEBUGGING_PORT ?? "9222")
diff --git a/packages/desktop/src/renderer/window/zoom.ts b/packages/desktop/src/renderer/window/zoom.ts
index 1a21863d820..28f1a2b7623 100644
--- a/packages/desktop/src/renderer/window/zoom.ts
+++ b/packages/desktop/src/renderer/window/zoom.ts
@@ -70,16 +70,19 @@ api.onZoomFactorChanged((factor) => {
void api.getPinchZoomEnabled().then((enabled) => {
pinchZoomEnabled = enabled
+ syncWheelPinchListener()
})
api.onPinchZoomEnabledChanged((enabled) => {
pinchZoomEnabled = enabled
resetWheelPinch()
+ syncWheelPinchListener()
})
const setPinchZoomEnabled = (enabled: boolean) => {
pinchZoomEnabled = enabled
resetWheelPinch()
+ syncWheelPinchListener()
return api.setPinchZoomEnabled(enabled)
}
@@ -129,18 +132,24 @@ const updateWheelPinch = (event: WheelEvent) => {
applyZoom(clamp(wheelPinch.startZoom - (wheelPinch.totalDelta / WHEEL_PINCH_THRESHOLD) * WHEEL_PINCH_STEP))
}
-window.addEventListener(
- "wheel",
- (event) => {
- if (!pinchZoomEnabled) return
+const onWheelPinch = (event: WheelEvent) => {
+ if (!event.ctrlKey) return
- if (!event.ctrlKey) return
+ event.preventDefault()
+ updateWheelPinch(event)
+}
- event.preventDefault()
- updateWheelPinch(event)
- },
- { passive: false },
-)
+// A cancelable window wheel listener makes every scroll gesture wait for the main thread, so it
+// is only attached while pinch zoom is on. Main's zoom-changed handler covers Ctrl+wheel otherwise.
+const syncWheelPinchListener = () => {
+ if (pinchZoomEnabled) {
+ window.addEventListener("wheel", onWheelPinch, { passive: false })
+
+ return
+ }
+
+ window.removeEventListener("wheel", onWheelPinch)
+}
window.addEventListener("keydown", (event) => {
if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
diff --git a/packages/ui/src/components/scroll-view.tsx b/packages/ui/src/components/scroll-view.tsx
index 8d92074637a..e46cec7af82 100644
--- a/packages/ui/src/components/scroll-view.tsx
+++ b/packages/ui/src/components/scroll-view.tsx
@@ -397,8 +397,7 @@ export function ScrollView(props: ScrollViewProps) {
// Keybinds implementation
// We ensure the viewport has a tabindex so it can receive focus
- // We can also explicitly catch PageUp/Down if we want smooth scroll or specific behavior,
- // but native usually handles this perfectly. Let's explicitly ensure it behaves well.
+ // Keyboard scrolling jumps immediately, like the wheel, instead of animating.
const onKeyDown = (e: KeyboardEvent) => {
// If user is focused on an input inside the scroll view, don't hijack keys
if (document.activeElement && ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement.tagName)) {
@@ -434,27 +433,27 @@ export function ScrollView(props: ScrollViewProps) {
switch (next) {
case "page-down":
e.preventDefault()
- viewportRef.scrollBy({ top: scrollAmount, behavior: "smooth" })
+ viewportRef.scrollBy({ top: scrollAmount })
break
case "page-up":
e.preventDefault()
- viewportRef.scrollBy({ top: -scrollAmount, behavior: "smooth" })
+ viewportRef.scrollBy({ top: -scrollAmount })
break
case "home":
e.preventDefault()
- viewportRef.scrollTo({ top: 0, behavior: "smooth" })
+ viewportRef.scrollTo({ top: 0 })
break
case "end":
e.preventDefault()
- viewportRef.scrollTo({ top: viewportRef.scrollHeight, behavior: "smooth" })
+ viewportRef.scrollTo({ top: viewportRef.scrollHeight })
break
case "up":
e.preventDefault()
- viewportRef.scrollBy({ top: -lineAmount, behavior: "smooth" })
+ viewportRef.scrollBy({ top: -lineAmount })
break
case "down":
e.preventDefault()
- viewportRef.scrollBy({ top: lineAmount, behavior: "smooth" })
+ viewportRef.scrollBy({ top: lineAmount })
break
}
}
@@ -484,13 +483,17 @@ export function ScrollView(props: ScrollViewProps) {
if (typeof events.onScroll === "function") events.onScroll(e as any)
}}
- onWheel={(e) => {
- markScrolling()
- const handler = events.onWheel
+ // Passive so the compositor never waits on the main thread to scroll; onWheel cannot cancel.
+ on:wheel={{
+ passive: true,
+ handleEvent: (e) => {
+ markScrolling()
+ const handler = events.onWheel
- if (typeof handler === "function") handler(e as any)
+ if (typeof handler === "function") handler(e as any)
- if (Array.isArray(handler)) handler[0](handler[1], e as any)
+ if (Array.isArray(handler)) handler[0](handler[1], e as any)
+ },
}}
onTouchStart={events.onTouchStart as any}
onTouchMove={events.onTouchMove as any}