diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 53d81ecae0..e24a239f0d 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -96,7 +96,10 @@ const LazySettingsScreen = React.lazy(async () => { export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); - useWebviewScrollBoundaryLock(); + const compositorTimelineScrollEnabled = useFeatureEnabled( + "compositorTimelineScroll", + ); + useWebviewScrollBoundaryLock(!compositorTimelineScrollEnabled); const workspacesHook = useWorkspaces(); const workspaceRailEnabled = useFeatureEnabled("workspaceRail"); diff --git a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts index 883c1eeeb7..dff298b8bc 100644 --- a/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts +++ b/desktop/src/shared/hooks/useWebviewScrollBoundaryLock.ts @@ -80,8 +80,12 @@ function isConversationScroller(element: HTMLElement) { * remain; every other boundary — including all horizontal ones — is locked and * cannot chain to the viewport. */ -export function useWebviewScrollBoundaryLock() { +export function useWebviewScrollBoundaryLock(enabled = true) { React.useEffect(() => { + if (!enabled) { + return; + } + function handleWheel(event: WheelEvent) { if (event.defaultPrevented || event.ctrlKey) { return; @@ -137,5 +141,5 @@ export function useWebviewScrollBoundaryLock() { return () => { window.removeEventListener("wheel", handleWheel, { capture: true }); }; - }, []); + }, [enabled]); } diff --git a/desktop/tests/e2e/overscroll-boundary.spec.ts b/desktop/tests/e2e/overscroll-boundary.spec.ts index 5aab62d89a..d3e7a5b4bb 100644 --- a/desktop/tests/e2e/overscroll-boundary.spec.ts +++ b/desktop/tests/e2e/overscroll-boundary.spec.ts @@ -28,7 +28,38 @@ async function dispatchWheelPrevented( } test.beforeEach(async ({ page }) => { - await installMockBridge(page); + // Boundary-lock contracts exercise the stable/default path. Most E2E suites + // opt every preview feature in, but compositorTimelineScroll intentionally + // removes this listener so trackpad scrolling can stay off the main thread. + await installMockBridge(page, undefined, { seedPreviewFeatures: false }); +}); + +test("compositor timeline scrolling leaves timeline wheel events unhandled", async ({ + page, +}) => { + await page.addInitScript(() => { + localStorage.setItem( + "buzz-feature-overrides-v1", + JSON.stringify({ compositorTimelineScroll: true }), + ); + }); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toBeVisible(); + + // The diagnostic path removes the global non-passive listener rather than + // merely returning early for conversations. The fixed-height shell and CSS + // overscroll backstop remain; JS no longer blocks timeline scrolling. + await expect( + dispatchWheelPrevented(page, '[data-testid="message-timeline"]', { + deltaY: -120, + }), + ).resolves.toBe(false); + await expect( + dispatchWheelPrevented(page, '[data-testid="message-timeline"]', { + deltaX: -120, + }), + ).resolves.toBe(false); }); test("locks viewport rubber-band outside conversation scrollers", async ({ diff --git a/preview-features.json b/preview-features.json index bc5f6d16c0..41ef752fe3 100644 --- a/preview-features.json +++ b/preview-features.json @@ -40,6 +40,14 @@ "platforms": [ "desktop" ] + }, + { + "id": "compositorTimelineScroll", + "name": "Compositor timeline scrolling", + "description": "Lets the webview handle timeline trackpad gestures without a global blocking wheel listener", + "platforms": [ + "desktop" + ] } ] }