From 039246f00b913988f5c1e3f272dd531790c11451 Mon Sep 17 00:00:00 2001 From: Aidan Date: Sat, 13 Jun 2026 23:45:27 -0700 Subject: [PATCH 1/5] [aidan] ui/ux: middle-drag and horizontal scroll over browser/chat pan the canvas --- electron/webview-preload.js | 112 ++++++++++++++---- .../src/app/pages/AgentChat/AgentChat.tsx | 3 + .../app/pages/Dashboard/cards/BrowserCard.tsx | 15 +-- .../hooks/interaction/useCanvasControls.ts | 34 +++--- .../useOverlayScrollPassthrough.ts | 10 ++ 5 files changed, 125 insertions(+), 49 deletions(-) diff --git a/electron/webview-preload.js b/electron/webview-preload.js index ab4aa519..073a875c 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -137,39 +137,59 @@ try { }); // --------------------------------------------------------------------------- - // Canvas zoom passthrough (ctrl/meta + wheel) + // Horizontal scroll passthrough to canvas pan // - // A is an out-of-process Chromium guest; wheel events that - // originate inside it never bubble to the embedding renderer. Without - // intercepting here, ctrl+wheel over a browser card just zooms the - // embedded page (Chromium's default) and the dashboard canvas never - // sees the gesture — issue #27. - // - // Capture-phase + passive:false so we run before the page's own listeners - // and can preventDefault to suppress the in-page page-zoom. We then - // forward the gesture (deltaY + guest-local cursor coords) to the host - // via sendToHost; BrowserCard's ipc-message handler turns it back into a - // synthetic WheelEvent dispatched from the webview element, which bubbles - // naturally to useCanvasControls' wheel listener. + // is an out-of-process guest; wheel events inside it never bubble + // to the embedding renderer. Vertical scroll and ctrl/meta+wheel zoom stay + // with the page (chromium default). A horizontal-dominant gesture, however, + // should pan the dashboard canvas if the guest page has nothing horizontal + // to scroll, to match the behavior over chat panels (which never have a + // horizontal scroller and always pan the canvas). + const pageCanScrollX = (node, dx) => { + let t = node; + while (t) { + const sw = t.scrollWidth || 0; + const cw = t.clientWidth || 0; + if (sw > cw) { + let style; + try { style = getComputedStyle(t); } catch (_) {} + const ox = style ? style.overflowX : 'visible'; + if (ox === 'auto' || ox === 'scroll') { + const atRight = t.scrollLeft + cw >= sw - 1; + const atLeft = t.scrollLeft <= 1; + const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); + if (!atBoundary) return true; + } + } + t = t.parentElement; + } + const docEl = document.scrollingElement || document.documentElement; + if (docEl && docEl.scrollWidth > docEl.clientWidth) { + const atRight = docEl.scrollLeft + docEl.clientWidth >= docEl.scrollWidth - 1; + const atLeft = docEl.scrollLeft <= 1; + const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); + if (!atBoundary) return true; + } + return false; + }; + const onWheelCapture = (e) => { - if (!(e.ctrlKey || e.metaKey)) return; + // Pinch / ctrl+wheel stays with the page (chromium's in-page zoom). + if (e.ctrlKey || e.metaKey) 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; e.preventDefault(); e.stopPropagation(); try { - console.warn('[openswarm:webview-preload] ctrl+wheel intercept → sendToHost', { - deltaY: e.deltaY, - clientX: e.clientX, - clientY: e.clientY, - }); - ipcRenderer.sendToHost('canvas-wheel-zoom', { + ipcRenderer.sendToHost('canvas-wheel-pan', { + deltaX: e.deltaX, deltaY: e.deltaY, deltaMode: e.deltaMode, - clientX: e.clientX, - clientY: e.clientY, }); - } catch (err) { - console.warn('[openswarm:webview-preload] sendToHost failed', err); - } + } catch (_) {} }; // Listen on both window and document in capture phase so we run before any // page-level handler that might swallow the event. passive:false is required @@ -177,6 +197,48 @@ try { window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); + // --------------------------------------------------------------------------- + // Middle-mouse-button drag → canvas pan + // + // Empty canvas and agent cards already get middle-button pan because the + // event bubbles to the dashboard's mousedown handler. is a + // separate compositor layer that eats mouse events, so middle-drag over a + // browser silently did nothing. Intercept here and forward the per-event + // movement as a pan delta through the existing canvas-wheel-pan channel + // (negated, since drag pans panX += dx while wheel pans panX -= dx). + // Always pans regardless of capture state — middle-drag is unambiguously + // a canvas gesture. + let middleDragging = false; + const onMouseDownMiddle = (e) => { + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + middleDragging = true; + }; + const onMouseMoveMiddle = (e) => { + if (!middleDragging) return; + e.preventDefault(); + e.stopPropagation(); + const dx = e.movementX || 0; + const dy = e.movementY || 0; + if (dx === 0 && dy === 0) return; + try { + ipcRenderer.sendToHost('canvas-wheel-pan', { deltaX: -dx, deltaY: -dy, deltaMode: 0 }); + } catch (_) {} + }; + const onMouseUpMiddle = (e) => { + if (e.button !== 1) return; + middleDragging = false; + }; + // Chromium starts auxiliary-scroll on middle-click; auxclick prevents that. + const onAuxClickSuppress = (e) => { + if (e.button === 1) { e.preventDefault(); e.stopPropagation(); } + }; + window.addEventListener('mousedown', onMouseDownMiddle, { capture: true }); + window.addEventListener('mousemove', onMouseMoveMiddle, { capture: true }); + window.addEventListener('mouseup', onMouseUpMiddle, { capture: true }); + window.addEventListener('auxclick', onAuxClickSuppress, { capture: true }); + // --------------------------------------------------------------------------- // Double-click to fit the browser card (parity with agent-chat dblclick). // diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index be0a2436..6e05643e 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -664,6 +664,9 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // Without this early-out the unconditional stopPropagation below kills // ctrl+wheel and the canvas listener never fires. if (e.ctrlKey || e.metaKey) return; + // Horizontal-dominant gestures must also reach the canvas so a sideways + // swipe pans the dashboard (chat has no horizontal scroll to absorb). + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; const atTop = el.scrollTop <= 0; const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; const scrollingDown = e.deltaY > 0; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index d6b53b13..b8f06675 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -303,7 +303,7 @@ const BrowserCard: React.FC = ({ // (the historical Windows mount segfault). Clear the crash-safety marker. if (isWindows) markWindowsWebviewSurvived(); wv.loadURL(targetUrl).catch(() => {}); - // Lock guest zoom at 1.0 so ctrl+wheel never triggers Chromium's in-page zoom; canvas zoom takes over (issue #27). + // Disable in-guest pinch zoom; page zoom (cmd+= / cmd+-) still works via chromium. try { (wv as any).setVisualZoomLevelLimits?.(1, 1); (wv as any).setZoomFactor?.(1); @@ -328,19 +328,16 @@ const BrowserCard: React.FC = ({ setPasskeyDialogOpen(true); } else if (e?.channel === 'browser-dblclick') { onDoubleClickRef.current?.(browserId, 'browser'); - } else if (e?.channel === 'canvas-wheel-zoom') { - // Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView. + } else if (e?.channel === 'canvas-wheel-pan') { + // Plain wheel inside an unselected webview never bubbles out; the + // preload forwards it here so the dashboard canvas can pan. const payload = e.args?.[0] || {}; - const wvRect = wv.getBoundingClientRect(); - const docX = wvRect.left + (payload.clientX ?? 0); - const docY = wvRect.top + (payload.clientY ?? 0); window.dispatchEvent( - new CustomEvent('openswarm:canvas-wheel-zoom', { + new CustomEvent('openswarm:canvas-wheel-pan', { detail: { + deltaX: payload.deltaX ?? 0, deltaY: payload.deltaY ?? 0, deltaMode: payload.deltaMode ?? 0, - clientX: docX, - clientY: docY, }, }), ); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 89302524..66446189 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -261,6 +261,15 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; + + // Horizontal-dominant gestures over a container that only scrolls + // vertically (e.g., chat) should pan the canvas instead of being + // silently absorbed by the child's no-op horizontal handling. + if (Math.abs(dx) > Math.abs(dy) && !canScrollX) { + target = target.parentElement; + continue; + } + const atYBoundary = !canScrollY || (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || (dy < 0 && target.scrollTop <= 1); @@ -302,31 +311,26 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: el.addEventListener('wheel', onWheel, { passive: false }); - // ctrl/meta+wheel events that originate inside an Electron - // never bubble out of the guest into the host DOM, so the wheel - // listener above can't see them. BrowserCard's preload-bridge - // forwards those gestures via this CustomEvent (issue #27); we run - // the same zoom-around-cursor math the wheel handler uses. - const onForwardedZoom = (e: Event) => { + // Plain wheel inside a webview can't bubble out either; the preload + // forwards horizontal-dominant scrolls as a pan when the guest page + // has nothing to scroll horizontally, plus middle-mouse drag deltas. + const onForwardedPan = (e: Event) => { const detail = (e as CustomEvent).detail || {}; - const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY; - const rect = el.getBoundingClientRect(); + const dy = detail.deltaMode === 1 ? (detail.deltaY ?? 0) * 40 : (detail.deltaY ?? 0); + const dx = detail.deltaMode === 1 ? (detail.deltaX ?? 0) * 40 : (detail.deltaX ?? 0); if (inertiaFrameRef.current) { cancelAnimationFrame(inertiaFrameRef.current); inertiaFrameRef.current = null; } - pendingZoomDy += dy; - pendingZoomCenter = { - cx: (detail.clientX ?? 0) - rect.left, - cy: (detail.clientY ?? 0) - rect.top, - }; + pendingPanDx += dx; + pendingPanDy += dy; scheduleWheelFlush(); }; - window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); + window.addEventListener('openswarm:canvas-wheel-pan', onForwardedPan); return () => { el.removeEventListener('wheel', onWheel); - window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); + window.removeEventListener('openswarm:canvas-wheel-pan', onForwardedPan); if (wheelRafId != null) cancelAnimationFrame(wheelRafId); if (wheelIdleTimer != null) clearTimeout(wheelIdleTimer); // Don't leave the flag stuck on if the canvas unmounts mid-gesture. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index e2294bdf..2bdd7ddb 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -22,6 +22,8 @@ export function useOverlayScrollPassthrough(active: boolean) { dy *= 20; } + const horizontalDominant = Math.abs(dx) > Math.abs(dy); + let node = underneath as HTMLElement | null; while (node) { if (node.tagName === 'WEBVIEW') { @@ -52,6 +54,14 @@ export function useOverlayScrollPassthrough(active: boolean) { node.scrollWidth > node.clientWidth && (cs.overflowX === 'auto' || cs.overflowX === 'scroll'); + // Horizontal-dominant gesture over a vertically-only scrollable + // container: don't absorb it (scrollBy with dx would be a no-op). + // Let it bubble to the canvas wheel handler so the canvas pans. + if (horizontalDominant && !canScrollX) { + node = node.parentElement; + continue; + } + if (canScrollY || canScrollX) { e.stopPropagation(); e.preventDefault(); From c61a8d1de052491ae76535a486e7c432951916b4 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 00:08:38 -0700 Subject: [PATCH 2/5] [eric] revert: drop #82 middle-drag/horizontal-scroll canvas pan (undo merge) --- electron/webview-preload.js | 112 ++++-------------- .../src/app/pages/AgentChat/AgentChat.tsx | 3 - .../app/pages/Dashboard/cards/BrowserCard.tsx | 15 ++- .../hooks/interaction/useCanvasControls.ts | 34 +++--- .../useOverlayScrollPassthrough.ts | 10 -- 5 files changed, 49 insertions(+), 125 deletions(-) diff --git a/electron/webview-preload.js b/electron/webview-preload.js index 073a875c..ab4aa519 100644 --- a/electron/webview-preload.js +++ b/electron/webview-preload.js @@ -137,59 +137,39 @@ try { }); // --------------------------------------------------------------------------- - // Horizontal scroll passthrough to canvas pan + // Canvas zoom passthrough (ctrl/meta + wheel) // - // is an out-of-process guest; wheel events inside it never bubble - // to the embedding renderer. Vertical scroll and ctrl/meta+wheel zoom stay - // with the page (chromium default). A horizontal-dominant gesture, however, - // should pan the dashboard canvas if the guest page has nothing horizontal - // to scroll, to match the behavior over chat panels (which never have a - // horizontal scroller and always pan the canvas). - const pageCanScrollX = (node, dx) => { - let t = node; - while (t) { - const sw = t.scrollWidth || 0; - const cw = t.clientWidth || 0; - if (sw > cw) { - let style; - try { style = getComputedStyle(t); } catch (_) {} - const ox = style ? style.overflowX : 'visible'; - if (ox === 'auto' || ox === 'scroll') { - const atRight = t.scrollLeft + cw >= sw - 1; - const atLeft = t.scrollLeft <= 1; - const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); - if (!atBoundary) return true; - } - } - t = t.parentElement; - } - const docEl = document.scrollingElement || document.documentElement; - if (docEl && docEl.scrollWidth > docEl.clientWidth) { - const atRight = docEl.scrollLeft + docEl.clientWidth >= docEl.scrollWidth - 1; - const atLeft = docEl.scrollLeft <= 1; - const atBoundary = (dx > 0 && atRight) || (dx < 0 && atLeft); - if (!atBoundary) return true; - } - return false; - }; - + // A is an out-of-process Chromium guest; wheel events that + // originate inside it never bubble to the embedding renderer. Without + // intercepting here, ctrl+wheel over a browser card just zooms the + // embedded page (Chromium's default) and the dashboard canvas never + // sees the gesture — issue #27. + // + // Capture-phase + passive:false so we run before the page's own listeners + // and can preventDefault to suppress the in-page page-zoom. We then + // forward the gesture (deltaY + guest-local cursor coords) to the host + // via sendToHost; BrowserCard's ipc-message handler turns it back into a + // synthetic WheelEvent dispatched from the webview element, which bubbles + // naturally to useCanvasControls' wheel listener. const onWheelCapture = (e) => { - // Pinch / ctrl+wheel stays with the page (chromium's in-page zoom). - if (e.ctrlKey || e.metaKey) 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; + if (!(e.ctrlKey || e.metaKey)) return; e.preventDefault(); e.stopPropagation(); try { - ipcRenderer.sendToHost('canvas-wheel-pan', { - deltaX: e.deltaX, + console.warn('[openswarm:webview-preload] ctrl+wheel intercept → sendToHost', { + deltaY: e.deltaY, + clientX: e.clientX, + clientY: e.clientY, + }); + ipcRenderer.sendToHost('canvas-wheel-zoom', { deltaY: e.deltaY, deltaMode: e.deltaMode, + clientX: e.clientX, + clientY: e.clientY, }); - } catch (_) {} + } catch (err) { + console.warn('[openswarm:webview-preload] sendToHost failed', err); + } }; // Listen on both window and document in capture phase so we run before any // page-level handler that might swallow the event. passive:false is required @@ -197,48 +177,6 @@ try { window.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); document.addEventListener('wheel', onWheelCapture, { capture: true, passive: false }); - // --------------------------------------------------------------------------- - // Middle-mouse-button drag → canvas pan - // - // Empty canvas and agent cards already get middle-button pan because the - // event bubbles to the dashboard's mousedown handler. is a - // separate compositor layer that eats mouse events, so middle-drag over a - // browser silently did nothing. Intercept here and forward the per-event - // movement as a pan delta through the existing canvas-wheel-pan channel - // (negated, since drag pans panX += dx while wheel pans panX -= dx). - // Always pans regardless of capture state — middle-drag is unambiguously - // a canvas gesture. - let middleDragging = false; - const onMouseDownMiddle = (e) => { - if (e.button !== 1) return; - e.preventDefault(); - e.stopPropagation(); - middleDragging = true; - }; - const onMouseMoveMiddle = (e) => { - if (!middleDragging) return; - e.preventDefault(); - e.stopPropagation(); - const dx = e.movementX || 0; - const dy = e.movementY || 0; - if (dx === 0 && dy === 0) return; - try { - ipcRenderer.sendToHost('canvas-wheel-pan', { deltaX: -dx, deltaY: -dy, deltaMode: 0 }); - } catch (_) {} - }; - const onMouseUpMiddle = (e) => { - if (e.button !== 1) return; - middleDragging = false; - }; - // Chromium starts auxiliary-scroll on middle-click; auxclick prevents that. - const onAuxClickSuppress = (e) => { - if (e.button === 1) { e.preventDefault(); e.stopPropagation(); } - }; - window.addEventListener('mousedown', onMouseDownMiddle, { capture: true }); - window.addEventListener('mousemove', onMouseMoveMiddle, { capture: true }); - window.addEventListener('mouseup', onMouseUpMiddle, { capture: true }); - window.addEventListener('auxclick', onAuxClickSuppress, { capture: true }); - // --------------------------------------------------------------------------- // Double-click to fit the browser card (parity with agent-chat dblclick). // diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index 6e05643e..be0a2436 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -664,9 +664,6 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose // Without this early-out the unconditional stopPropagation below kills // ctrl+wheel and the canvas listener never fires. if (e.ctrlKey || e.metaKey) return; - // Horizontal-dominant gestures must also reach the canvas so a sideways - // swipe pans the dashboard (chat has no horizontal scroll to absorb). - if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) return; const atTop = el.scrollTop <= 0; const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1; const scrollingDown = e.deltaY > 0; diff --git a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx index b8f06675..d6b53b13 100644 --- a/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx +++ b/frontend/src/app/pages/Dashboard/cards/BrowserCard.tsx @@ -303,7 +303,7 @@ const BrowserCard: React.FC = ({ // (the historical Windows mount segfault). Clear the crash-safety marker. if (isWindows) markWindowsWebviewSurvived(); wv.loadURL(targetUrl).catch(() => {}); - // Disable in-guest pinch zoom; page zoom (cmd+= / cmd+-) still works via chromium. + // Lock guest zoom at 1.0 so ctrl+wheel never triggers Chromium's in-page zoom; canvas zoom takes over (issue #27). try { (wv as any).setVisualZoomLevelLimits?.(1, 1); (wv as any).setZoomFactor?.(1); @@ -328,16 +328,19 @@ const BrowserCard: React.FC = ({ setPasskeyDialogOpen(true); } else if (e?.channel === 'browser-dblclick') { onDoubleClickRef.current?.(browserId, 'browser'); - } else if (e?.channel === 'canvas-wheel-pan') { - // Plain wheel inside an unselected webview never bubbles out; the - // preload forwards it here so the dashboard canvas can pan. + } else if (e?.channel === 'canvas-wheel-zoom') { + // Convert guest coords to doc coords and dispatch a CustomEvent; synthetic WheelEvent bubble was unreliable through GuestView. const payload = e.args?.[0] || {}; + const wvRect = wv.getBoundingClientRect(); + const docX = wvRect.left + (payload.clientX ?? 0); + const docY = wvRect.top + (payload.clientY ?? 0); window.dispatchEvent( - new CustomEvent('openswarm:canvas-wheel-pan', { + new CustomEvent('openswarm:canvas-wheel-zoom', { detail: { - deltaX: payload.deltaX ?? 0, deltaY: payload.deltaY ?? 0, deltaMode: payload.deltaMode ?? 0, + clientX: docX, + clientY: docY, }, }), ); diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts index 66446189..89302524 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useCanvasControls.ts @@ -261,15 +261,6 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: // Re-read scrollHeight/clientHeight; cached decision is structural, scroll position is dynamic. const canScrollY = target.scrollHeight > target.clientHeight; const canScrollX = target.scrollWidth > target.clientWidth; - - // Horizontal-dominant gestures over a container that only scrolls - // vertically (e.g., chat) should pan the canvas instead of being - // silently absorbed by the child's no-op horizontal handling. - if (Math.abs(dx) > Math.abs(dy) && !canScrollX) { - target = target.parentElement; - continue; - } - const atYBoundary = !canScrollY || (dy > 0 && target.scrollTop + target.clientHeight >= target.scrollHeight - 1) || (dy < 0 && target.scrollTop <= 1); @@ -311,26 +302,31 @@ export function useCanvasControls(zoomSensitivity: number = 50, contentBounds?: el.addEventListener('wheel', onWheel, { passive: false }); - // Plain wheel inside a webview can't bubble out either; the preload - // forwards horizontal-dominant scrolls as a pan when the guest page - // has nothing to scroll horizontally, plus middle-mouse drag deltas. - const onForwardedPan = (e: Event) => { + // ctrl/meta+wheel events that originate inside an Electron + // never bubble out of the guest into the host DOM, so the wheel + // listener above can't see them. BrowserCard's preload-bridge + // forwards those gestures via this CustomEvent (issue #27); we run + // the same zoom-around-cursor math the wheel handler uses. + const onForwardedZoom = (e: Event) => { const detail = (e as CustomEvent).detail || {}; - const dy = detail.deltaMode === 1 ? (detail.deltaY ?? 0) * 40 : (detail.deltaY ?? 0); - const dx = detail.deltaMode === 1 ? (detail.deltaX ?? 0) * 40 : (detail.deltaX ?? 0); + const dy = detail.deltaMode === 1 ? detail.deltaY * 40 : detail.deltaY; + const rect = el.getBoundingClientRect(); if (inertiaFrameRef.current) { cancelAnimationFrame(inertiaFrameRef.current); inertiaFrameRef.current = null; } - pendingPanDx += dx; - pendingPanDy += dy; + pendingZoomDy += dy; + pendingZoomCenter = { + cx: (detail.clientX ?? 0) - rect.left, + cy: (detail.clientY ?? 0) - rect.top, + }; scheduleWheelFlush(); }; - window.addEventListener('openswarm:canvas-wheel-pan', onForwardedPan); + window.addEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); return () => { el.removeEventListener('wheel', onWheel); - window.removeEventListener('openswarm:canvas-wheel-pan', onForwardedPan); + window.removeEventListener('openswarm:canvas-wheel-zoom', onForwardedZoom); if (wheelRafId != null) cancelAnimationFrame(wheelRafId); if (wheelIdleTimer != null) clearTimeout(wheelIdleTimer); // Don't leave the flag stuck on if the canvas unmounts mid-gesture. diff --git a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts index 2bdd7ddb..e2294bdf 100644 --- a/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts +++ b/frontend/src/app/pages/Dashboard/hooks/interaction/useOverlayScrollPassthrough.ts @@ -22,8 +22,6 @@ export function useOverlayScrollPassthrough(active: boolean) { dy *= 20; } - const horizontalDominant = Math.abs(dx) > Math.abs(dy); - let node = underneath as HTMLElement | null; while (node) { if (node.tagName === 'WEBVIEW') { @@ -54,14 +52,6 @@ export function useOverlayScrollPassthrough(active: boolean) { node.scrollWidth > node.clientWidth && (cs.overflowX === 'auto' || cs.overflowX === 'scroll'); - // Horizontal-dominant gesture over a vertically-only scrollable - // container: don't absorb it (scrollBy with dx would be a no-op). - // Let it bubble to the canvas wheel handler so the canvas pans. - if (horizontalDominant && !canScrollX) { - node = node.parentElement; - continue; - } - if (canScrollY || canScrollX) { e.stopPropagation(); e.preventDefault(); From 2d7be0e8ac11af4bb5cd8a75734a86aa1cfad6fe Mon Sep 17 00:00:00 2001 From: Aidan Date: Sun, 14 Jun 2026 01:22:13 -0700 Subject: [PATCH 3/5] [aidan] bug: dashboard not renaming during first run --- backend/apps/dashboards/dashboards.py | 8 ++++---- backend/tests/test_disk_resilience.py | 2 +- .../app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts | 7 +------ .../Dashboard/hooks/lifecycle/useDashboardLifecycle.ts | 7 ++----- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/backend/apps/dashboards/dashboards.py b/backend/apps/dashboards/dashboards.py index 81dbf0c1..36a02648 100644 --- a/backend/apps/dashboards/dashboards.py +++ b/backend/apps/dashboards/dashboards.py @@ -61,8 +61,8 @@ def _delete(dashboard_id: str): os.remove(path) -def _migrate_if_needed(): - """One-time migration: if no dashboards exist, create 'Dashboard 1' from old layout.""" +def migrate_if_needed(): + """One-time migration: if no dashboards exist, create the default from old layout.""" existing = _load_all() if existing: return @@ -80,7 +80,7 @@ def _migrate_if_needed(): except Exception: logger.exception("Failed to read old layout.json, using empty layout") - dashboard = Dashboard(name="Dashboard 1", layout=layout) + dashboard = Dashboard(name="Untitled Dashboard", layout=layout) _save(dashboard) logger.info(f"Created default dashboard: {dashboard.id}") @@ -105,7 +105,7 @@ def _migrate_if_needed(): @asynccontextmanager async def dashboards_lifespan(): os.makedirs(DATA_DIR, exist_ok=True) - _migrate_if_needed() + migrate_if_needed() yield diff --git a/backend/tests/test_disk_resilience.py b/backend/tests/test_disk_resilience.py index 9b37cbae..f1993c34 100644 --- a/backend/tests/test_disk_resilience.py +++ b/backend/tests/test_disk_resilience.py @@ -143,7 +143,7 @@ def test_migration_survives_corrupt_session(tmp_path, monkeypatch): (sess_dir / "good.json").write_text(json.dumps({"id": "good"})) (sess_dir / "bad.json").write_text("{ truncated ,,,") - dmod._migrate_if_needed() # must not raise despite the corrupt session + dmod.migrate_if_needed() # must not raise despite the corrupt session dashboards = dmod._load_all() assert len(dashboards) == 1 diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts index 7a8c3a18..e1ff4cbc 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useAgentSpawn.ts @@ -205,12 +205,7 @@ export function useAgentSpawn({ (s) => s.status !== 'draft' && s.dashboard_id === dashboardId, ).length; const NAME_GEN_TRIGGERS = [1, 3, 6]; - const currentDash = store.getState().dashboards.items[dashboardId]; - const canAutoName = - currentDash && - (currentDash.auto_named || currentDash.name === 'Untitled Dashboard'); - - if (NAME_GEN_TRIGGERS.includes(agentCount) && canAutoName) { + if (NAME_GEN_TRIGGERS.includes(agentCount)) { dispatch(generateDashboardName(dashboardId)); } } diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index b0b51f65..cfc3a428 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -255,16 +255,13 @@ export function useDashboardLifecycle({ const namedOnFirstMessageRef = useRef(null); useEffect(() => { - if (!dashboardId || !layoutInitialized) return; + if (!dashboardId) return; if (namedOnFirstMessageRef.current === dashboardId) return; - const dash = store.getState().dashboards.items[dashboardId]; - if (!dash) return; - if (!dash.auto_named && dash.name !== 'Untitled Dashboard') return; const hasUserMessage = Object.values(sessions).some( (s) => s.dashboard_id === dashboardId && s.messages?.some((m) => m.role === 'user'), ); if (!hasUserMessage) return; namedOnFirstMessageRef.current = dashboardId; dispatch(generateDashboardName(dashboardId)); - }, [sessions, dashboardId, layoutInitialized, dispatch]); + }, [sessions, dashboardId, dispatch]); } From 2e3ce84572c778cc69d365147499ed8e4ba60d34 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 03:59:57 -0700 Subject: [PATCH 4/5] [eric] build: gate mouse-clamp extraResources to macOS so the Windows build can't trip --- electron/package.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/electron/package.json b/electron/package.json index cd402307..aa816113 100644 --- a/electron/package.json +++ b/electron/package.json @@ -51,7 +51,16 @@ "category": "public.app-category.developer-tools", "hardenedRuntime": true, "entitlements": "build/entitlements.mac.plist", - "entitlementsInherit": "build/entitlements.mac.plist" + "entitlementsInherit": "build/entitlements.mac.plist", + "extraResources": [ + { + "from": "build-staging/mouseclamp/${arch}", + "to": "mouseclamp", + "filter": [ + "**/*" + ] + } + ] }, "dmg": { "artifactName": "OpenSwarm-${arch}.${ext}", @@ -149,13 +158,6 @@ "**/*" ] }, - { - "from": "build-staging/mouseclamp/${arch}", - "to": "mouseclamp", - "filter": [ - "**/*" - ] - }, { "from": "build-staging/uv-bin/${arch}", "to": "backend/uv-bin", From 78fbbebccd1d50df4f8f00b3531ba682b2448bd3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Sun, 14 Jun 2026 04:59:03 -0700 Subject: [PATCH 5/5] [eric] release: bump version to 1.2.84 (off-window crash fix + #81 rename + onboarding revamp) --- electron/package-lock.json | 4 ++-- electron/package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/electron/package-lock.json b/electron/package-lock.json index 96e0bad9..89267711 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.84", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openswarm", - "version": "1.2.77", + "version": "1.2.84", "hasInstallScript": true, "dependencies": { "electron-updater": "6.8.3", diff --git a/electron/package.json b/electron/package.json index aa816113..af023c3f 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "openswarm", - "version": "1.2.83", + "version": "1.2.84", "description": "OpenSwarm — AI Agent Orchestrator", "author": "openswarm-ai", "main": "main.js",