diff --git a/electron/webview-preload.js b/electron/webview-preload.js index cbb51b7d..0cd3b4f3 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -163,6 +163,15 @@ try { }); } catch (_) {} + // Scroll-focus (Google Maps model): plain wheel zooms the canvas over this card UNLESS the user + // has clicked INTO it, in which case plain wheel scrolls the page/app content. Host pushes the flag. + let scrollFocused = false; + try { + ipcRenderer.on('openswarm:set-scroll-focus', (_event, payload) => { + scrollFocused = !!(payload && payload.focused); + }); + } catch (_) {} + // First in-guest mousedown tells the host to activate interact mode. Never // preventDefault so the click still reaches the app (Minecraft etc). const onMouseDownNotify = (e) => { @@ -225,20 +234,43 @@ try { } catch (_) {} return; } - if (isInteractive) return; - // Vertical-dominant scroll stays with the page. - if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; - // Horizontal-dominant: defer to the page if anything inside can absorb - // it; otherwise forward to the host as a canvas pan. - if (pageCanScrollX(e.target, e.deltaX)) return; + // Focused (clicked-into) card, or an interactive app: wheel stays with the page/app content. + if (isInteractive || scrollFocused) { + // Vertical-dominant scroll stays with the page. + if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; + // Horizontal-dominant: defer to the page if anything inside can absorb it; otherwise forward as a canvas pan. + if (pageCanScrollX(e.target, e.deltaX)) return; + e.preventDefault(); + e.stopPropagation(); + try { + ipcRenderer.sendToHost('canvas-wheel-pan', { + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + }); + } catch (_) {} + return; + } + // Not focused: navigate the CANVAS while hovering the card (Google Maps). Horizontal-dominant swipe pans; vertical scroll zooms at the cursor. e.preventDefault(); e.stopPropagation(); try { - ipcRenderer.sendToHost('canvas-wheel-pan', { - deltaX: e.deltaX, - deltaY: e.deltaY, - deltaMode: e.deltaMode, - }); + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { + ipcRenderer.sendToHost('canvas-wheel-pan', { + deltaX: e.deltaX, + deltaY: e.deltaY, + deltaMode: e.deltaMode, + }); + } else { + const iw2 = window.innerWidth || 1; + const ih2 = window.innerHeight || 1; + ipcRenderer.sendToHost('canvas-wheel-zoom', { + deltaY: e.deltaY, + deltaMode: e.deltaMode, + fracX: Math.max(0, Math.min(1, e.clientX / iw2)), + fracY: Math.max(0, Math.min(1, e.clientY / ih2)), + }); + } } catch (_) {} }; // Listen on both window and document in capture phase so we run before any diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index 748fa9d3..67ec79b0 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -51,6 +51,7 @@ import { type BrowserWebview, } from '@/shared/browserRegistry'; import { setLastInteractedBrowser } from '@/shared/browserFocus'; +import { onScrollFocusChange, getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { registerCapsuleForRestore } from '@/shared/browserStateCapsule'; import BrowserFindBar from './BrowserFindBar'; import { useBrowserActivity } from '@/shared/useBrowserActivity'; @@ -271,6 +272,19 @@ const BrowserCard: React.FC = ({ setRegistryActiveTab(browserId, activeTabId); }, [browserId, activeTabId]); + // Tell the (out-of-process) guest whether plain wheel should scroll the page or zoom the canvas. + // Focused (clicked-into) = scroll the page; otherwise scroll-to-zoom works while hovering it (Google Maps). + useEffect(() => { + const push = (focusedId: string | null) => { + const focused = focusedId === browserId; + for (const wv of webviewMap.current.values()) { + try { wv.send?.('openswarm:set-scroll-focus', { focused }); } catch (_e) { /* guest not ready */ } + } + }; + push(getScrollFocusedCard()); + return onScrollFocusChange(push); + }, [browserId, activeTabId]); + // Open the find bar when AppShell routes a Ctrl/Cmd+F to this browser; re-trigger re-focuses the input. useEffect(() => { const onFind = (e: Event) => { diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index d6bbe20f..9c10795d 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useRef, useEffect, useMemo, RefObject } from 'react'; import { setCanvasInteractionActive } from '@/shared/canvasInteractionState'; import { getLastInteractedBrowser } from '@/shared/browserFocus'; +import { getScrollFocusedCard } from '@/shared/cardScrollFocus'; import { getWebview } from '@/shared/browserRegistry'; import { applyBrowserZoom } from '@/shared/browserZoom'; @@ -264,6 +265,13 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: } if (cls === 'scrollable' && !isModifierWheel) { + // Google Maps model: plain scroll zooms the canvas over ANY card (chat, app, scheduled task) UNLESS you've clicked INTO that card to read it. So a scrollable child only eats the wheel when its card is the scroll-focused one; otherwise fall through to canvas zoom. + const cardEl = target.closest('[data-select-id]'); + const cardId = cardEl?.getAttribute('data-select-id') ?? null; + if (!cardId || cardId !== getScrollFocusedCard()) { + target = target.parentElement; + continue; + } // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts index 1c66a144..b4126d03 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useDashboardInteractions.ts @@ -4,6 +4,7 @@ import { useAppDispatch } from '@/shared/hooks'; import { store } from '@/shared/state/store'; import { collapseSession, expandSession } from '@/shared/state/agentsSlice'; import { bringToFront } from '@/shared/state/dashboardLayoutSlice'; +import { setScrollFocusedCard } from '@/shared/cardScrollFocus'; import type { CardType, useDashboardSelection } from '../state/useDashboardSelection'; import type { useCanvasControls } from './useCanvasControls'; @@ -52,6 +53,8 @@ export function useDashboardInteractions({ selection.selectCard(id, type, false); dispatch(bringToFront({ id, type })); + // Clicking INTO a card focuses it for scrolling: plain wheel now reads its content instead of zooming the canvas (Google Maps model). Clicking blank canvas clears it (below). + setScrollFocusedCard(id); // The Workflows window is an app you click around inside, not a card you re-center every tap. Single-click only raises + selects it; double-click still zoom-to-fits (handleCardDoubleClick). Without this, clicking any button inside it yanked the canvas into a re-zoom. if (type === 'workflows-hub' || type === 'workflows-monitor') return; @@ -109,6 +112,8 @@ export function useDashboardInteractions({ if (agentDriven) return; selection.selectCard(browserId, 'browser', false); dispatch(bringToFront({ id: browserId, type: 'browser' })); + // Clicking inside a browser's page focuses it: plain wheel now scrolls the page instead of zooming the canvas. + setScrollFocusedCard(browserId); }; window.addEventListener('openswarm:browser-guest-select', onGuestSelect); return () => window.removeEventListener('openswarm:browser-guest-select', onGuestSelect); @@ -130,6 +135,9 @@ export function useDashboardInteractions({ if (e.button !== 0) return; if (isCardTarget(e.target, e.currentTarget)) return; + // Clicking blank canvas leaves every card: plain scroll zooms the canvas again (Google Maps model). + setScrollFocusedCard(null); + // Canvas click, drop any lingering input focus so arrow-key nav works immediately without the user having to press Escape first. const active = document.activeElement as HTMLElement | null; const activeTag = active?.tagName; diff --git a/frontend/src/shared/browserRegistry.ts b/frontend/src/shared/browserRegistry.ts index 1828da69..6ee28c4b 100644 --- a/frontend/src/shared/browserRegistry.ts +++ b/frontend/src/shared/browserRegistry.ts @@ -25,6 +25,7 @@ export interface BrowserWebview extends HTMLElement { isLoading: () => boolean; // Optional: present on real Electron webviews; the iframe fallback lacks it, callers must ?.() it. isCurrentlyAudible?: () => boolean; + send?: (channel: string, ...args: any[]) => void; capturePage: (rect?: { x: number; y: number; width: number; height: number }) => Promise; executeJavaScript: (code: string) => Promise; sendInputEvent: (event: any) => void; diff --git a/frontend/src/shared/cardScrollFocus.ts b/frontend/src/shared/cardScrollFocus.ts new file mode 100644 index 00000000..c81f9d2b --- /dev/null +++ b/frontend/src/shared/cardScrollFocus.ts @@ -0,0 +1,22 @@ +// The card you've clicked INTO, so plain scroll reads its content (chat transcript, web page) +// while scroll everywhere else zooms the canvas (Google Maps model). Imperative + read on the +// wheel handler so no re-render; cleared when you click blank canvas. +let scrollFocusedCardId: string | null = null; +type Listener = (id: string | null) => void; +const listeners = new Set(); + +export function setScrollFocusedCard(id: string | null): void { + if (scrollFocusedCardId === id) return; + scrollFocusedCardId = id; + for (const l of listeners) l(id); +} + +export function getScrollFocusedCard(): string | null { + return scrollFocusedCardId; +} + +// Browser cards subscribe so they can tell their (out-of-process) guest whether plain wheel should scroll the page or zoom the canvas. +export function onScrollFocusChange(l: Listener): () => void { + listeners.add(l); + return () => { listeners.delete(l); }; +}