diff --git a/backend/apps/settings/models.py b/backend/apps/settings/models.py index 8c691fe9..60415924 100644 --- a/backend/apps/settings/models.py +++ b/backend/apps/settings/models.py @@ -43,6 +43,11 @@ class AppSettings(BaseModel): default_max_turns: Optional[int] = None default_thinking_level: Literal["off", "low", "medium", "high", "auto"] = "auto" zoom_sensitivity: float = 50.0 + # What a plain MOUSE wheel does on the canvas. "zoom" is the Google-Maps model we ship; "scroll" + # suits people who expect a wheel to move the page, and swaps the pair so cmd/ctrl+wheel zooms + # instead. A trackpad two-finger scroll pans either way, since that gesture is already a pan + # everywhere else on the machine. + mouse_wheel_action: Literal["zoom", "scroll"] = "zoom" # Root font-size multiplier (0.9/1/1.1/1.2 from Settings > Interface); the whole rem type scale rides it. ui_font_scale: float = 1.0 theme: str = "light" diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index c8a3caf6..dd5089f7 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -50,7 +50,12 @@ export interface ContentBounds { maxY: number; } -export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: ContentBounds, enabled: boolean = true) { +export function useCanvasControls( + zoomSensitivity: number = 50, + contentBounds?: ContentBounds, + enabled: boolean = true, + wheelAction: 'zoom' | 'scroll' = 'zoom', +) { const viewportRef = useRef(null); const contentRef = useRef(null); const gridRef = useRef(null); @@ -67,6 +72,11 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: const spaceRef = useRef(false); const sensitivityRef = useRef(zoomSensitivity); sensitivityRef.current = zoomSensitivity; + // Read through a ref, like sensitivity: the wheel listener is bound once per mount, so a plain + // closure over the prop would keep the value the canvas had when it mounted and the setting + // would appear to do nothing until you switched dashboards. + const wheelActionRef = useRef(wheelAction); + wheelActionRef.current = wheelAction; const contentBoundsRef = useRef(contentBounds); contentBoundsRef.current = contentBounds; const animFrameRef = useRef(null); @@ -311,6 +321,12 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: if (selectFullscreenCardId(store.getState())) return; // ctrl/cmd wheel is the zoom gesture on every surface: a physically held key or a trackpad pinch (which also sets ctrlKey). It bypasses scrollable children so zoom is always reachable, even over a chat you're typing in. const isModifierWheel = e.ctrlKey || e.metaKey; + // The setting swaps which of the two a bare mouse notch does. A PINCH must keep zooming + // whatever the setting says: it sets ctrlKey but there is no key held, and nobody pinches to + // scroll. So only a real held key counts as the swap trigger, and `e.ctrlKey && !isPinch` + // cannot be used here because Chromium reports a pinch identically to ctrl+wheel; the + // trackpad classifier is what tells them apart. + const wheelZooms = wheelActionRef.current !== 'scroll'; // Let scrollable children handle the event when appropriate, but fall through to canvas pan if the child is at its scroll boundary. const dy = e.deltaMode === 1 ? e.deltaY * 40 : e.deltaY; @@ -392,12 +408,18 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Horizontal-dominant mouse scroll (tilt wheel) → pan X. Dominant-axis, so the vertical jitter in a sideways swipe doesn't also zoom. pendingPanDx += dx; scheduleWheelFlush(); - } else { + } else if (wheelZooms) { // Mouse-wheel vertical notch → zoom at the cursor (same anchor as pinch) so the point under the pointer grows toward you, not away. Clamp the per-event delta so a discrete notch is a small step, not a lurch. const rect = el.getBoundingClientRect(); pendingZoomDy += clamp(dy, -WHEEL_ZOOM_DELTA_CAP, WHEEL_ZOOM_DELTA_CAP); pendingZoomCenter = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; scheduleWheelFlush(); + } else { + // Setting says a wheel scrolls: pan vertically instead. Zoom is still reachable on + // cmd/ctrl+wheel, which the isModifierWheel branch above already handles, so the two + // gestures simply trade places rather than one of them going missing. + pendingPanDy += dy; + scheduleWheelFlush(); } }; diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts index 09acbe98..0e076678 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardController.ts @@ -35,7 +35,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { workflowCards, workflowItems, workflowOpenCards, workflowsHub, pendingFocusWorkflowId, pendingFocusWorkflowsHub, layoutInitialized, persistedExpandedSessionIds, - zoomSensitivity, newAgentShortcut, browserHomepage, expandNewChats, + zoomSensitivity, mouseWheelAction, newAgentShortcut, browserHomepage, expandNewChats, autoRevealSubAgents, outputs, outputsLoaded, glowingAgentCards, glowingBrowserCards, } = useDashboardSelectors(dashboardId); // sessions is the top-level dict; useMemo on its identity so sessionList is stable when sessions hasn't actually changed (RTK only swaps the dict ref when one of its values changes, so this is the right granularity). @@ -68,7 +68,7 @@ export function useDashboardController(dashboardId: string, isActive: boolean) { [cards, viewCards, browserCards, workflowCards, workflowsHub], ); - const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive); + const canvas = useCanvasControls(zoomSensitivity, contentBounds, isActive, mouseWheelAction); const selection = useDashboardSelection( { panX: canvas.panX, panY: canvas.panY, zoom: canvas.zoom, viewportRef: canvas.viewportRef }, cards, diff --git a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts index 6065d621..a3a82be8 100644 --- a/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts +++ b/frontend/src/app/pages/Dashboard/hooks/state/useDashboardSelectors.ts @@ -36,6 +36,7 @@ export function useDashboardSelectors(dashboardId: string) { const layoutInitialized = useAppSelector((state) => state.dashboardLayout.initialized); const persistedExpandedSessionIds = useAppSelector((state) => state.dashboardLayout.persistedExpandedSessionIds); const zoomSensitivity = useAppSelector((state) => state.settings.data.zoom_sensitivity); + const mouseWheelAction = useAppSelector((state) => state.settings.data.mouse_wheel_action); const newAgentShortcut = useAppSelector((state) => state.settings.data.new_agent_shortcut); const browserHomepage = useAppSelector((state) => state.settings.data.browser_homepage); const expandNewChats = useAppSelector((state) => state.settings.data.expand_new_chats_in_dashboard); @@ -62,6 +63,7 @@ export function useDashboardSelectors(dashboardId: string) { layoutInitialized, persistedExpandedSessionIds, zoomSensitivity, + mouseWheelAction, newAgentShortcut, browserHomepage, expandNewChats, diff --git a/frontend/src/shared/state/settingsSlice.ts b/frontend/src/shared/state/settingsSlice.ts index 7fb32645..97394eac 100644 --- a/frontend/src/shared/state/settingsSlice.ts +++ b/frontend/src/shared/state/settingsSlice.ts @@ -43,6 +43,8 @@ export interface AppSettings { default_max_turns: number | null; default_thinking_level: 'off' | 'low' | 'medium' | 'high' | 'auto'; zoom_sensitivity: number; + /** What a plain mouse wheel does on the canvas; trackpad two-finger always pans. */ + mouse_wheel_action: 'zoom' | 'scroll'; theme: 'light' | 'dark'; new_agent_shortcut: string; dictation_shortcut?: string | null; @@ -163,6 +165,7 @@ export const DEFAULT_SETTINGS: AppSettings = { default_max_turns: null, default_thinking_level: 'auto', zoom_sensitivity: 50, + mouse_wheel_action: 'zoom', ui_font_scale: 1, voice_hold_to_talk: true, theme: 'light',